Use owner enums for error and T-Bank protocol values with ESLint regression checks. Preserve open wire fallbacks and keep protocol imports independent of DI and Prisma. Verified npm ci, verify, 226 unit tests, 262 isolated e2e tests and unchanged OpenAPI.
217 lines
6.4 KiB
TypeScript
217 lines
6.4 KiB
TypeScript
import {
|
|
ConflictException,
|
|
Injectable,
|
|
UnauthorizedException,
|
|
} from '@nestjs/common';
|
|
import { JwtService } from '@nestjs/jwt';
|
|
import * as bcrypt from 'bcrypt';
|
|
import { createHash, randomUUID } from 'crypto';
|
|
|
|
import { Session, User } from '../../../../generated/prisma/client';
|
|
import { HttpErrorCode } from '../../../common/error-code';
|
|
import { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code';
|
|
import { PrismaService } from '../../../prisma/prisma.service';
|
|
import { AuthErrorCode } from './auth-error-code';
|
|
import { AuthUser, RefreshPayload } from './auth.types';
|
|
|
|
const EXPECTED_ROTATED_SESSIONS = 1;
|
|
|
|
const PASSWORD_HASH_ROUNDS = 12;
|
|
const REFRESH_TTL_MS = 604_800_000;
|
|
|
|
const ACCESS_TTL = '15m';
|
|
const REFRESH_TTL = '7d';
|
|
const DUMMY_PASSWORD_HASH =
|
|
// eslint-disable-next-line sonarjs/no-hardcoded-passwords -- Public dummy bcrypt hash preserves login timing for nonexistent users; it is not a credential.
|
|
'$2b$12$Z49BwJsyiUqY0ZozFzt9IeYvjhV4kbeLw9KdxOguT5bfhA1RvPCx6';
|
|
|
|
@Injectable()
|
|
export class AuthService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly jwt: JwtService,
|
|
) {}
|
|
|
|
async register(
|
|
email: string,
|
|
password: string,
|
|
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
|
const existing = await this.prisma.user.findUnique({
|
|
where: { email },
|
|
});
|
|
if (existing) {
|
|
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
|
|
}
|
|
|
|
let user: User;
|
|
try {
|
|
user = await this.prisma.user.create({
|
|
data: {
|
|
id: randomUUID(),
|
|
email,
|
|
passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS),
|
|
},
|
|
});
|
|
} catch (error) {
|
|
if (isUniqueConstraintError(error)) {
|
|
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
|
|
}
|
|
throw error;
|
|
}
|
|
return this.createSession(user);
|
|
}
|
|
|
|
async login(
|
|
email: string,
|
|
password: string,
|
|
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
|
const user = await this.prisma.user.findUnique({
|
|
where: { email },
|
|
});
|
|
const valid = await bcrypt.compare(
|
|
password,
|
|
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
|
|
);
|
|
if (!user || !valid) {
|
|
throw new UnauthorizedException({ code: AuthErrorCode.InvalidCredentials });
|
|
}
|
|
return this.createSession(user);
|
|
}
|
|
|
|
async refresh(
|
|
token: string,
|
|
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
|
let payload: RefreshPayload;
|
|
try {
|
|
payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
|
|
algorithms: ['HS256'],
|
|
});
|
|
} catch {
|
|
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
|
}
|
|
if (payload.type !== 'refresh') {
|
|
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
|
}
|
|
const session = await this.prisma.session.findUnique({
|
|
where: { id: payload.sid },
|
|
include: { user: true },
|
|
});
|
|
if (
|
|
!session ||
|
|
session.userId !== payload.sub ||
|
|
session.revokedAt ||
|
|
session.expiresAt <= new Date()
|
|
) {
|
|
if (session && !session.revokedAt) {
|
|
await this.prisma.session.delete({ where: { id: session.id } });
|
|
}
|
|
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
|
}
|
|
if (session.refreshTokenHash !== this.hash(token)) {
|
|
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
|
}
|
|
return this.rotateSession(session, session.refreshTokenHash);
|
|
}
|
|
|
|
async logout(token?: string): Promise<void> {
|
|
if (!token) return;
|
|
let payload: RefreshPayload;
|
|
try {
|
|
payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
|
|
algorithms: ['HS256'],
|
|
});
|
|
} catch {
|
|
return;
|
|
}
|
|
if (payload.type !== 'refresh') return;
|
|
const session = await this.prisma.session.findUnique({
|
|
where: { id: payload.sid },
|
|
});
|
|
if (
|
|
session &&
|
|
!session.revokedAt &&
|
|
session.refreshTokenHash === this.hash(token)
|
|
) {
|
|
await this.prisma.session.update({
|
|
where: { id: session.id },
|
|
data: { revokedAt: new Date() },
|
|
});
|
|
}
|
|
}
|
|
|
|
toUser(user: User): AuthUser {
|
|
return { id: user.id, email: user.email };
|
|
}
|
|
|
|
async userById(id: string): Promise<User> {
|
|
const user = await this.prisma.user.findUnique({ where: { id } });
|
|
if (!user) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
|
return user;
|
|
}
|
|
|
|
private async createSession(
|
|
user: User,
|
|
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
|
const session = await this.prisma.session.create({
|
|
data: {
|
|
id: randomUUID(),
|
|
userId: user.id,
|
|
refreshTokenHash: '',
|
|
expiresAt: new Date(),
|
|
},
|
|
});
|
|
return this.rotateSession({ ...session, user });
|
|
}
|
|
|
|
private async rotateSession(
|
|
session: Session & { user: User },
|
|
expectedRefreshTokenHash?: string,
|
|
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
|
const refreshToken = await this.jwt.signAsync(
|
|
{
|
|
sub: session.userId,
|
|
sid: session.id,
|
|
type: 'refresh',
|
|
jti: randomUUID(),
|
|
},
|
|
{ expiresIn: REFRESH_TTL, algorithm: 'HS256' },
|
|
);
|
|
const data = {
|
|
refreshTokenHash: this.hash(refreshToken),
|
|
expiresAt: new Date(Date.now() + REFRESH_TTL_MS),
|
|
};
|
|
if (expectedRefreshTokenHash) {
|
|
const updated = await this.prisma.session.updateMany({
|
|
where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash },
|
|
data,
|
|
});
|
|
if (updated.count !== EXPECTED_ROTATED_SESSIONS) {
|
|
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
|
}
|
|
} else {
|
|
await this.prisma.session.update({ where: { id: session.id }, data });
|
|
}
|
|
return {
|
|
user: this.toUser(session.user),
|
|
accessToken: await this.jwt.signAsync(
|
|
{ sub: session.userId, sid: session.id, type: 'access' },
|
|
{ expiresIn: ACCESS_TTL, algorithm: 'HS256' },
|
|
),
|
|
refreshToken,
|
|
};
|
|
}
|
|
|
|
private hash(token: string): string {
|
|
return createHash('sha256').update(token).digest('hex');
|
|
}
|
|
}
|
|
|
|
function isUniqueConstraintError(error: unknown): boolean {
|
|
return (
|
|
typeof error === 'object' &&
|
|
error !== null &&
|
|
'code' in error &&
|
|
isPrismaErrorCode(error.code, PrismaErrorCode.UniqueConstraint)
|
|
);
|
|
}
|