diff --git a/src/modules/auth/application/auth.service.spec.ts b/src/modules/auth/application/auth.service.spec.ts index 3e04a6d..b9e4930 100644 --- a/src/modules/auth/application/auth.service.spec.ts +++ b/src/modules/auth/application/auth.service.spec.ts @@ -2,6 +2,8 @@ import { ConflictException, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { createHash } from 'crypto'; +import { AuthRepository } from '../infrastructure/auth.repository'; + jest.mock('bcrypt', () => ({ hash: jest.fn(() => Promise.resolve('password-hash')), compare: jest.fn(() => Promise.resolve(true)), @@ -51,7 +53,7 @@ describe('AuthService', () => { delete: jest.fn(), }, }; - const service = new AuthService(prisma as never, jwt); + const service = new AuthService(new AuthRepository(prisma as never), jwt); beforeEach(() => { jest.clearAllMocks(); diff --git a/src/modules/auth/application/auth.service.ts b/src/modules/auth/application/auth.service.ts index f19cbc2..5682b57 100644 --- a/src/modules/auth/application/auth.service.ts +++ b/src/modules/auth/application/auth.service.ts @@ -9,13 +9,10 @@ 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 { AuthRepository } from '../infrastructure/auth.repository'; import { AuthErrorCode } from './auth-error-code'; import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types'; -const EXPECTED_ROTATED_SESSIONS = 1; - const PASSWORD_HASH_ROUNDS = 12; export const REFRESH_TTL_MS = 604_800_000; @@ -28,7 +25,7 @@ const DUMMY_PASSWORD_HASH = @Injectable() export class AuthService { constructor( - private readonly prisma: PrismaService, + private readonly repository: AuthRepository, private readonly jwt: JwtService, ) {} @@ -36,27 +33,18 @@ export class AuthService { email: string, password: string, ): Promise { - const existing = await this.prisma.user.findUnique({ - where: { email }, - }); + const existing = await this.repository.findUserByEmail(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; + const user = await this.repository.createUser({ + id: randomUUID(), + email, + passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS), + }); + if (!user) { + throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered }); } return this.createSession(user); } @@ -65,9 +53,7 @@ export class AuthService { email: string, password: string, ): Promise { - const user = await this.prisma.user.findUnique({ - where: { email }, - }); + const user = await this.repository.findUserByEmail(email); const valid = await bcrypt.compare( password, user?.passwordHash ?? DUMMY_PASSWORD_HASH, @@ -92,10 +78,7 @@ export class AuthService { if (payload.type !== 'refresh') { throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); } - const session = await this.prisma.session.findUnique({ - where: { id: payload.sid }, - include: { user: true }, - }); + const session = await this.repository.findSessionWithUser(payload.sid); if ( !session || session.userId !== payload.sub || @@ -103,7 +86,7 @@ export class AuthService { session.expiresAt <= new Date() ) { if (session && !session.revokedAt) { - await this.prisma.session.delete({ where: { id: session.id } }); + await this.repository.deleteSession(session.id); } throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); } @@ -124,18 +107,13 @@ export class AuthService { return; } if (payload.type !== 'refresh') return; - const session = await this.prisma.session.findUnique({ - where: { id: payload.sid }, - }); + const session = await this.repository.findSession(payload.sid); if ( session && !session.revokedAt && session.refreshTokenHash === this.hash(token) ) { - await this.prisma.session.update({ - where: { id: session.id }, - data: { revokedAt: new Date() }, - }); + await this.repository.revokeSession(session.id); } } @@ -144,7 +122,7 @@ export class AuthService { } async userById(id: string): Promise { - const user = await this.prisma.user.findUnique({ where: { id } }); + const user = await this.repository.findUserById(id); if (!user) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); return user; } @@ -152,14 +130,7 @@ export class AuthService { private async createSession( user: User, ): Promise { - const session = await this.prisma.session.create({ - data: { - id: randomUUID(), - userId: user.id, - refreshTokenHash: '', - expiresAt: new Date(), - }, - }); + const session = await this.repository.createSession(user.id, randomUUID()); return this.rotateSession({ ...session, user }); } @@ -180,16 +151,8 @@ export class AuthService { 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 }); + if (!(await this.repository.rotateSession(session.id, data, expectedRefreshTokenHash))) { + throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); } return { user: this.toUser(session.user), @@ -205,12 +168,3 @@ export class AuthService { 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) - ); -} diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 1d93f41..ccef8e5 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -5,6 +5,7 @@ import { PassportModule } from '@nestjs/passport'; import { Environment } from '../../config/env.validation'; import { AuthService } from './application/auth.service'; +import { AuthRepository } from './infrastructure/auth.repository'; import { JwtStrategy } from './infrastructure/jwt.strategy'; import { AuthController } from './presentation/auth.controller'; import { JwtAuthGuard } from './presentation/jwt-auth.guard'; @@ -22,6 +23,6 @@ import { JwtAuthGuard } from './presentation/jwt-auth.guard'; ], exports: [JwtAuthGuard], controllers: [AuthController], - providers: [AuthService, JwtStrategy, JwtAuthGuard], + providers: [AuthService, AuthRepository, JwtStrategy, JwtAuthGuard], }) export class AuthModule {} diff --git a/src/modules/auth/infrastructure/auth.repository.ts b/src/modules/auth/infrastructure/auth.repository.ts new file mode 100644 index 0000000..afc3515 --- /dev/null +++ b/src/modules/auth/infrastructure/auth.repository.ts @@ -0,0 +1,81 @@ +import { Injectable } from '@nestjs/common'; + +import { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code'; +import { PrismaService } from '../../../prisma/prisma.service'; + +const EXPECTED_ROTATED_SESSIONS = 1; + +@Injectable() +export class AuthRepository { + constructor(private readonly prisma: PrismaService) {} + + findUserByEmail(email: string) { + return this.prisma.user.findUnique({ where: { email } }); + } + + async createUser(data: { email: string; id: string; passwordHash: string }) { + try { + return await this.prisma.user.create({ data }); + } catch (error) { + if (isUniqueConstraintError(error)) return null; + throw error; + } + } + + findUserById(id: string) { + return this.prisma.user.findUnique({ where: { id } }); + } + + findSessionWithUser(id: string) { + return this.prisma.session.findUnique({ + where: { id }, + include: { user: true }, + }); + } + + findSession(id: string) { + return this.prisma.session.findUnique({ where: { id } }); + } + + deleteSession(id: string) { + return this.prisma.session.delete({ where: { id } }); + } + + revokeSession(id: string) { + return this.prisma.session.update({ + where: { id }, + data: { revokedAt: new Date() }, + }); + } + + createSession(userId: string, id: string) { + return this.prisma.session.create({ + data: { id, userId, refreshTokenHash: '', expiresAt: new Date() }, + }); + } + + async rotateSession( + id: string, + data: { expiresAt: Date; refreshTokenHash: string }, + expectedRefreshTokenHash?: string, + ): Promise { + if (expectedRefreshTokenHash) { + const updated = await this.prisma.session.updateMany({ + where: { id, refreshTokenHash: expectedRefreshTokenHash }, + data, + }); + return updated.count === EXPECTED_ROTATED_SESSIONS; + } + await this.prisma.session.update({ where: { id }, data }); + return true; + } +} + +function isUniqueConstraintError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + isPrismaErrorCode(error.code, PrismaErrorCode.UniqueConstraint) + ); +}