diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 773e33af..0e641fe6 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -50,14 +50,14 @@ import { UserRole } from '../types/prisma.types'; import { FraudService } from '../fraud/fraud.service'; type JwtPayload = { - sub: string; - email: string; - role: UserRole; - type: 'access' | 'refresh'; - jti: string; - family?: string; - exp?: number; - }; + sub: string; + email: string; + role: UserRole; + type: 'access' | 'refresh'; + jti: string; + family?: string; + exp?: number; +}; @Injectable() export class AuthService { @@ -264,40 +264,49 @@ export class AuthService { } } - async login(data: LoginDto, ipAddress?: string, userAgent?: string) { - await this.preflightChecks(data, ipAddress, userAgent); - - const user = await this.usersService.findByEmail(data.email); - if (!user) { - // Record failed attempt even if user doesn't exist (prevent enumeration) - await this.rateLimitService.recordFailedAttempt(data.email, ipAddress, userAgent); - throw new UnauthorizedException('Invalid credentials'); - } - + /** + * Verify a fetched user's credentials: account state checks (blocked/ + * deactivated/unverified) and bcrypt password comparison. + * + * Centralises the post-findByEmail gating plus the bcrypt compare and the + * rate-limit / fraud / lockout-email side effects. Extracted from login() + * for testability and readability (issue #744). + * + * @throws UnauthorizedException on any gate failure or password mismatch + */ + private async verifyCredentials( + user: { + id: string; + email: string; + password: string | null; + isBlocked: boolean; + isDeactivated: boolean; + isVerified: boolean; + }, + password: string, + ipAddress?: string, + userAgent?: string, + ): Promise { if (user.isBlocked) { throw new UnauthorizedException('Your account has been blocked. Please contact support.'); } - if (user.isDeactivated) { throw new UnauthorizedException( 'Your account has been deactivated. Please contact support to reactivate your account.', ); } - if (!user.isVerified) { throw new UnauthorizedException('Please verify your email before logging in.'); } - const passwordMatches = await comparePassword(data.password, user.password ?? ''); + const passwordMatches = await comparePassword(password, user.password ?? ''); if (!passwordMatches) { - // Record failed login attempt const shouldLock = await this.rateLimitService.recordFailedAttempt( - data.email, + user.email, ipAddress, userAgent, ); - - await this.fraudService.evaluateFailedLogin(data.email, ipAddress, userAgent); + await this.fraudService.evaluateFailedLogin(user.email, ipAddress, userAgent); if (shouldLock) { const lockoutDuration = 30; @@ -306,7 +315,6 @@ export class AuthService { `Failed to send account locked email to user ${user.id} (${this.hashEmail(user.email)}): ${err.message}`, ); }); - throw new UnauthorizedException( `Account locked due to too many failed login attempts. Please try again in ${lockoutDuration} minutes.`, ); @@ -314,6 +322,19 @@ export class AuthService { throw new UnauthorizedException('Invalid credentials'); } + } + + async login(data: LoginDto, ipAddress?: string, userAgent?: string) { + await this.preflightChecks(data, ipAddress, userAgent); + + const user = await this.usersService.findByEmail(data.email); + if (!user) { + // Record failed attempt even if user doesn't exist (prevent enumeration) + await this.rateLimitService.recordFailedAttempt(data.email, ipAddress, userAgent); + throw new UnauthorizedException('Invalid credentials'); + } + + await this.verifyCredentials(user, data.password, ipAddress, userAgent); if (user.twoFactorEnabled) { const hasTotpCode = Boolean(data.totpCode?.trim());