import { HttpException, HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { JwtService } from '@nestjs/jwt'; import { createHash, randomBytes } from 'node:crypto'; import { normalizeEmail } from '../common/normalize'; import { comparePassword, hasMinimumPasswordLength, hashPassword } from '../common/password'; import { getAppConfig } from '../config/env'; import { RedisQueueService } from '../jobs/redis-queue.service'; import { MailerService } from '../mailer/mailer.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuthenticatedUser } from './auth.types'; import { buildOtpAuthUrl, decryptSecret, encryptSecret, generateTotpSecret, verifyTotpCode } from './totp'; const config = getAppConfig(); const TWO_FACTOR_LOGIN_CHALLENGE_TTL = '10m'; const LOGIN_LIMITER = { scope: 'login', maxAttempts: config.authLoginMaxAttempts, windowMinutes: config.authLoginWindowMinutes, message: 'Too many login attempts.', } as const; const TWO_FACTOR_LIMITER = { scope: '2fa', maxAttempts: config.authTwoFactorMaxAttempts, windowMinutes: config.authTwoFactorWindowMinutes, message: 'Too many two-factor verification attempts.', } as const; const PASSWORD_RESET_LIMITER = { scope: 'password-reset', maxAttempts: config.authPasswordResetMaxAttempts, windowMinutes: config.authPasswordResetWindowMinutes, message: 'Too many password reset requests.', } as const; function formatSecurityTimestamp(value: Date) { return value.toISOString(); } type TwoFactorChallengePayload = AuthenticatedUser & { purpose: '2fa-login' }; type TwoFactorRecord = { twoFactorEnabled: boolean; twoFactorSecretEncrypted: string | null; twoFactorPendingSecretEncrypted: string | null; twoFactorRecoveryCodesHashJson: unknown; twoFactorConfirmedAt: Date | null; }; @Injectable() export class AuthService { constructor( private readonly jwtService: JwtService, private readonly prisma: PrismaService, private readonly redisQueueService: RedisQueueService, private readonly mailer: MailerService, ) {} async login(email: string, password: string, ipAddress?: string) { const normalizedEmail = normalizeEmail(email); await this.assertRateLimit(LOGIN_LIMITER, normalizedEmail, ipAddress); const user = await this.prisma.user.findUnique({ where: { email: normalizedEmail }, }); if (!user) { await this.recordFailedLogin({ email: normalizedEmail, ipAddress, reason: 'Unknown email', }); await this.registerFailedAttempt(LOGIN_LIMITER, normalizedEmail, ipAddress); throw new UnauthorizedException('Invalid email or password'); } if (!user.passwordHash) { await this.recordFailedLogin({ userId: user.id, name: user.name, email: user.email, ipAddress, reason: 'Password setup incomplete', }); await this.registerFailedAttempt(LOGIN_LIMITER, normalizedEmail, ipAddress); throw new UnauthorizedException('User account has not completed password setup'); } const isPasswordValid = await comparePassword(password, user.passwordHash); if (!isPasswordValid) { await this.recordFailedLogin({ userId: user.id, name: user.name, email: user.email, ipAddress, reason: 'Invalid password', }); await this.registerFailedAttempt(LOGIN_LIMITER, normalizedEmail, ipAddress); throw new UnauthorizedException('Invalid email or password'); } if (user.status !== 'active') { await this.recordFailedLogin({ userId: user.id, name: user.name, email: user.email, ipAddress, reason: `User status ${user.status}`, }); await this.registerFailedAttempt(LOGIN_LIMITER, normalizedEmail, ipAddress); throw new UnauthorizedException('User account is not active'); } const payload: AuthenticatedUser = { sub: user.id, email: user.email, ver: user.sessionVersion, }; await this.clearFailedAttempts(LOGIN_LIMITER, normalizedEmail, ipAddress); const twoFactor = await this.getTwoFactorRecord(user.id); if (twoFactor?.twoFactorEnabled && twoFactor.twoFactorSecretEncrypted) { const challengeToken = await this.jwtService.signAsync( { ...payload, purpose: '2fa-login', } satisfies TwoFactorChallengePayload, { secret: config.jwtSecret, expiresIn: TWO_FACTOR_LOGIN_CHALLENGE_TTL, }, ); return { requiresTwoFactor: true, challengeToken, challengeExpiresIn: TWO_FACTOR_LOGIN_CHALLENGE_TTL, user: { id: user.id, name: user.name, email: user.email, status: user.status, }, }; } const session = await this.issueSession(payload, user.id); await this.prisma.auditLog.create({ data: { actorUserId: user.id, actorName: user.name, actorEmail: user.email, actionType: 'Login Success', module: 'Auth Gateway', ipAddress: ipAddress || null, severity: 'default', details: `Successful login for ${user.email}.`, }, }); await this.prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() }, }); return { ...session, user: { id: user.id, name: user.name, email: user.email, status: user.status, }, }; } async verifyAccessToken(token: string) { const payload = await this.jwtService.verifyAsync(token, { secret: config.jwtSecret, }); const user = await this.prisma.user.findUnique({ where: { id: payload.sub }, select: { id: true, email: true, status: true, sessionVersion: true }, }); if (!user || user.status !== 'active' || user.sessionVersion !== payload.ver || user.email !== payload.email) { throw new UnauthorizedException('Invalid or expired token'); } return payload; } async refresh(refreshToken: string, ipAddress?: string) { const payload = await this.verifyRefreshToken(refreshToken); const user = await this.prisma.user.findUnique({ where: { id: payload.sub }, select: { id: true, name: true, email: true, status: true, sessionVersion: true, refreshTokenHash: true, refreshTokenExpiresAt: true, }, }); if ( !user || user.status !== 'active' || user.sessionVersion !== payload.ver || user.email !== payload.email || !user.refreshTokenHash || user.refreshTokenHash !== this.hashToken(refreshToken) || !user.refreshTokenExpiresAt || user.refreshTokenExpiresAt.getTime() <= Date.now() ) { throw new UnauthorizedException('Invalid or expired refresh token'); } const nextPayload: AuthenticatedUser = { sub: user.id, email: user.email, ver: user.sessionVersion, }; const session = await this.issueSession(nextPayload, user.id); await this.prisma.auditLog.create({ data: { actorUserId: user.id, actorName: user.name, actorEmail: user.email, actionType: 'Token Refreshed', module: 'Auth Gateway', ipAddress: ipAddress || null, severity: 'default', details: `Refreshed session for ${user.email}.`, }, }); return session; } async logout(input: { accessToken?: string; refreshToken?: string; ipAddress?: string; }) { let userId: string | null = null; let userEmail: string | null = null; let actorName: string | null = null; if (input.accessToken) { try { const payload = await this.verifyAccessToken(input.accessToken); userId = payload.sub; userEmail = payload.email; } catch { userId = null; } } if (!userId && input.refreshToken) { const payload = await this.verifyRefreshToken(input.refreshToken); userId = payload.sub; userEmail = payload.email; } if (!userId || !userEmail) { throw new UnauthorizedException('Unable to resolve session for logout'); } const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { id: true, name: true, email: true, sessionVersion: true }, }); if (!user) { throw new UnauthorizedException('User not found'); } actorName = user.name; await this.prisma.user.update({ where: { id: user.id }, data: { refreshTokenHash: null, refreshTokenExpiresAt: null, sessionVersion: { increment: 1, }, }, }); await this.prisma.auditLog.create({ data: { actorUserId: user.id, actorName, actorEmail: user.email, actionType: 'Logout Success', module: 'Auth Gateway', ipAddress: input.ipAddress || null, severity: 'default', details: `Logged out session for ${user.email}.`, }, }); return { success: true }; } async me(userId: string) { const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { id: true, name: true, email: true, status: true, createdAt: true, lastLoginAt: true, }, }); if (!user) { throw new UnauthorizedException('User not found'); } return user; } async getCurrentSession(authUser: AuthenticatedUser, ipAddress?: string) { const user = await this.prisma.user.findUnique({ where: { id: authUser.sub }, select: { id: true, name: true, email: true, status: true, role: { select: { name: true }, }, lastLoginAt: true, refreshTokenExpiresAt: true, twoFactorEnabled: true, twoFactorConfirmedAt: true, }, }); if (!user) { throw new UnauthorizedException('User not found'); } return { user: { id: user.id, name: user.name, email: user.email, status: user.status, roleName: user.role?.name || 'Unassigned', lastLoginAt: user.lastLoginAt, twoFactorEnabled: user.twoFactorEnabled, twoFactorConfirmedAt: user.twoFactorConfirmedAt, }, session: { issuedAt: authUser.iat ? new Date(authUser.iat * 1000).toISOString() : null, expiresAt: authUser.exp ? new Date(authUser.exp * 1000).toISOString() : null, refreshExpiresAt: user.refreshTokenExpiresAt, currentIp: ipAddress || null, policy: 'single-session', }, }; } async updateProfile(authUser: AuthenticatedUser, dto: { name?: string }, ipAddress?: string) { const current = await this.prisma.user.findUnique({ where: { id: authUser.sub }, select: { id: true, name: true, email: true }, }); if (!current) { throw new UnauthorizedException('User not found'); } const name = dto.name !== undefined ? dto.name.trim() : current.name; if (!name) { throw new HttpException('Name is required', HttpStatus.BAD_REQUEST); } const user = await this.prisma.user.update({ where: { id: current.id }, data: { name, }, include: { role: { select: { name: true }, }, }, }); await this.prisma.auditLog.create({ data: { actorUserId: user.id, actorName: user.name, actorEmail: user.email, actionType: 'Profile Updated', module: 'Account Profile', ipAddress: ipAddress || null, severity: 'default', details: `Updated profile for ${user.email}.`, }, }); const response = { id: user.id, name: user.name, email: user.email, status: user.status, roleName: user.role?.name || 'Unassigned', lastLoginAt: user.lastLoginAt, twoFactorEnabled: user.twoFactorEnabled, twoFactorConfirmedAt: user.twoFactorConfirmedAt, }; return response; } async changePassword(authUser: AuthenticatedUser, currentPassword: string, newPassword: string, ipAddress?: string) { if (!hasMinimumPasswordLength(newPassword)) { throw new HttpException('New password must be at least 8 characters', HttpStatus.BAD_REQUEST); } const user = await this.prisma.user.findUnique({ where: { id: authUser.sub }, select: { id: true, name: true, email: true, passwordHash: true, }, }); if (!user || !user.passwordHash) { throw new UnauthorizedException('User not found'); } const isPasswordValid = await comparePassword(currentPassword, user.passwordHash); if (!isPasswordValid) { throw new UnauthorizedException('Current password is incorrect'); } await this.prisma.user.update({ where: { id: user.id }, data: { passwordHash: await hashPassword(newPassword), sessionVersion: { increment: 1 }, refreshTokenHash: null, refreshTokenExpiresAt: null, }, }); await this.prisma.auditLog.create({ data: { actorUserId: user.id, actorName: user.name, actorEmail: user.email, actionType: 'Password Changed', module: 'Account Profile', ipAddress: ipAddress || null, severity: 'warning', details: `Changed password for ${user.email}.`, }, }); return { success: true }; } async getTwoFactorStatus(userId: string) { const twoFactor = await this.getTwoFactorRecord(userId); if (!twoFactor) { throw new UnauthorizedException('User not found'); } return { enabled: twoFactor.twoFactorEnabled, pendingSetup: Boolean(twoFactor.twoFactorPendingSecretEncrypted), confirmedAt: twoFactor.twoFactorConfirmedAt, recoveryCodesRemaining: this.readRecoveryCodeHashes(twoFactor).length, }; } async initiateTwoFactorSetup(userId: string) { const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { id: true, email: true }, }); if (!user) { throw new UnauthorizedException('User not found'); } const secret = generateTotpSecret(); await this.prisma.$executeRaw( Prisma.sql`UPDATE "users" SET "two_factor_pending_secret_encrypted" = ${encryptSecret(secret, config.jwtSecret)} WHERE "id" = CAST(${user.id} AS uuid)`, ); return { manualEntryKey: secret, otpauthUrl: buildOtpAuthUrl(secret, user.email, 'BizOne'), }; } async confirmTwoFactorSetup(userId: string, code: string, ipAddress?: string) { const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { id: true, name: true, email: true, }, }); const twoFactor = user ? await this.getTwoFactorRecord(user.id) : null; if (!user || !twoFactor?.twoFactorPendingSecretEncrypted) { throw new UnauthorizedException('Two-factor setup is not initialized'); } const secret = decryptSecret(twoFactor.twoFactorPendingSecretEncrypted, config.jwtSecret); if (!verifyTotpCode(secret, code)) { throw new UnauthorizedException('Invalid verification code'); } const recoveryCodes = this.generateRecoveryCodes(); const recoveryCodeHashes = recoveryCodes.map((value) => this.hashRecoveryCode(value)); await this.prisma.$executeRaw( Prisma.sql` UPDATE "users" SET "two_factor_enabled" = true, "two_factor_secret_encrypted" = ${twoFactor.twoFactorPendingSecretEncrypted}, "two_factor_pending_secret_encrypted" = null, "two_factor_recovery_codes_hash_json" = ${JSON.stringify(recoveryCodeHashes)}::jsonb, "two_factor_confirmed_at" = ${new Date()}, "refresh_token_hash" = null, "refresh_token_expires_at" = null, "session_version" = "session_version" + 1 WHERE "id" = CAST(${user.id} AS uuid) `, ); await this.prisma.auditLog.create({ data: { actorUserId: user.id, actorName: user.name, actorEmail: user.email, actionType: 'Two Factor Enabled', module: 'Auth Gateway', ipAddress: ipAddress || null, severity: 'default', details: `Enabled 2FA for ${user.email}.`, }, }); await this.sendSecurityNotificationSafely({ to: user.email, name: user.name, subject: 'BizOne security alert: 2FA enabled', heading: 'Two-Factor Authentication Enabled', intro: 'Two-factor authentication was enabled for your BizOne admin account.', bullets: [ `Account: ${user.email}`, `Time: ${formatSecurityTimestamp(new Date())}`, `IP address: ${ipAddress || 'Unavailable'}`, ], note: 'If you did not enable 2FA yourself, reset your password and contact an administrator immediately.', }); return { success: true, recoveryCodes, }; } async disableTwoFactor(userId: string, code: string, ipAddress?: string) { const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { id: true, name: true, email: true, }, }); const twoFactor = user ? await this.getTwoFactorRecord(user.id) : null; if (!user || !twoFactor?.twoFactorEnabled || !twoFactor.twoFactorSecretEncrypted) { throw new UnauthorizedException('Two-factor authentication is not enabled'); } const secret = decryptSecret(twoFactor.twoFactorSecretEncrypted, config.jwtSecret); if (!verifyTotpCode(secret, code)) { throw new UnauthorizedException('Invalid verification code'); } await this.prisma.$executeRaw( Prisma.sql` UPDATE "users" SET "two_factor_enabled" = false, "two_factor_secret_encrypted" = null, "two_factor_pending_secret_encrypted" = null, "two_factor_recovery_codes_hash_json" = null, "two_factor_confirmed_at" = null, "refresh_token_hash" = null, "refresh_token_expires_at" = null, "session_version" = "session_version" + 1 WHERE "id" = CAST(${user.id} AS uuid) `, ); await this.prisma.auditLog.create({ data: { actorUserId: user.id, actorName: user.name, actorEmail: user.email, actionType: 'Two Factor Disabled', module: 'Auth Gateway', ipAddress: ipAddress || null, severity: 'alert', details: `Disabled 2FA for ${user.email}.`, }, }); await this.sendSecurityNotificationSafely({ to: user.email, name: user.name, subject: 'BizOne security alert: 2FA disabled', heading: 'Two-Factor Authentication Disabled', intro: 'Two-factor authentication was disabled for your BizOne admin account.', bullets: [ `Account: ${user.email}`, `Time: ${formatSecurityTimestamp(new Date())}`, `IP address: ${ipAddress || 'Unavailable'}`, ], note: 'If this was not you, reset your password immediately and re-enable 2FA after regaining control.', }); return { success: true }; } async regenerateTwoFactorRecoveryCodes(userId: string, code: string, ipAddress?: string) { const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { id: true, name: true, email: true, }, }); const twoFactor = user ? await this.getTwoFactorRecord(user.id) : null; if (!user || !twoFactor?.twoFactorEnabled || !twoFactor.twoFactorSecretEncrypted) { throw new UnauthorizedException('Two-factor authentication is not enabled'); } const secret = decryptSecret(twoFactor.twoFactorSecretEncrypted, config.jwtSecret); if (!verifyTotpCode(secret, code)) { throw new UnauthorizedException('Invalid verification code'); } const recoveryCodes = this.generateRecoveryCodes(); const recoveryCodeHashes = recoveryCodes.map((value) => this.hashRecoveryCode(value)); await this.prisma.$executeRaw( Prisma.sql` UPDATE "users" SET "two_factor_recovery_codes_hash_json" = ${JSON.stringify(recoveryCodeHashes)}::jsonb WHERE "id" = CAST(${user.id} AS uuid) `, ); await this.prisma.auditLog.create({ data: { actorUserId: user.id, actorName: user.name, actorEmail: user.email, actionType: 'Two Factor Recovery Codes Regenerated', module: 'Auth Gateway', ipAddress: ipAddress || null, severity: 'alert', details: `Regenerated 2FA recovery codes for ${user.email}.`, }, }); return { success: true, recoveryCodes, }; } async verifyTwoFactorLogin(challengeToken: string, code: string, ipAddress?: string) { const payload = await this.jwtService.verifyAsync(challengeToken, { secret: config.jwtSecret, }); if (payload.purpose !== '2fa-login') { throw new UnauthorizedException('Invalid challenge token'); } const user = await this.prisma.user.findUnique({ where: { id: payload.sub }, select: { id: true, name: true, email: true, status: true, sessionVersion: true, }, }); const twoFactor = user ? await this.getTwoFactorRecord(user.id) : null; if ( !user || user.status !== 'active' || user.sessionVersion !== payload.ver || user.email !== payload.email || !twoFactor?.twoFactorEnabled || !twoFactor.twoFactorSecretEncrypted ) { throw new UnauthorizedException('Invalid challenge token'); } await this.assertRateLimit(TWO_FACTOR_LIMITER, user.email, ipAddress); const secret = decryptSecret(twoFactor.twoFactorSecretEncrypted, config.jwtSecret); const normalizedCode = this.normalizeRecoveryCode(code); const recoveryCodeHashes = this.readRecoveryCodeHashes(twoFactor); const isTotpValid = verifyTotpCode(secret, code); const matchedRecoveryCodeIndex = recoveryCodeHashes.findIndex( (candidate) => candidate === this.hashRecoveryCode(normalizedCode), ); if (!isTotpValid && matchedRecoveryCodeIndex === -1) { await this.registerFailedAttempt(TWO_FACTOR_LIMITER, user.email, ipAddress); await this.prisma.auditLog.create({ data: { actorUserId: user.id, actorName: user.name, actorEmail: user.email, actionType: 'Two Factor Login Failed', module: 'Auth Gateway', ipAddress: ipAddress || null, severity: 'alert', details: `Failed 2FA verification for ${user.email}.`, }, }); throw new UnauthorizedException('Invalid verification code'); } if (!isTotpValid && matchedRecoveryCodeIndex >= 0) { recoveryCodeHashes.splice(matchedRecoveryCodeIndex, 1); await this.prisma.$executeRaw( Prisma.sql` UPDATE "users" SET "two_factor_recovery_codes_hash_json" = ${JSON.stringify(recoveryCodeHashes)}::jsonb WHERE "id" = CAST(${user.id} AS uuid) `, ); await this.prisma.auditLog.create({ data: { actorUserId: user.id, actorName: user.name, actorEmail: user.email, actionType: 'Two Factor Recovery Code Used', module: 'Auth Gateway', ipAddress: ipAddress || null, severity: 'alert', details: `Used a backup recovery code for ${user.email}.`, }, }); await this.sendSecurityNotificationSafely({ to: user.email, name: user.name, subject: 'BizOne security alert: recovery code used', heading: 'Backup Recovery Code Used', intro: 'A backup recovery code was used to complete a BizOne admin login.', bullets: [ `Account: ${user.email}`, `Time: ${formatSecurityTimestamp(new Date())}`, `IP address: ${ipAddress || 'Unavailable'}`, `Recovery codes remaining: ${recoveryCodeHashes.length}`, ], note: 'If this was not you, rotate your password and regenerate backup codes as soon as possible.', }); } await this.clearFailedAttempts(TWO_FACTOR_LIMITER, user.email, ipAddress); const session = await this.issueSession( { sub: user.id, email: user.email, ver: user.sessionVersion, }, user.id, ); await this.prisma.auditLog.create({ data: { actorUserId: user.id, actorName: user.name, actorEmail: user.email, actionType: 'Two Factor Login Success', module: 'Auth Gateway', ipAddress: ipAddress || null, severity: 'default', details: `Completed 2FA login for ${user.email}.`, }, }); await this.prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() }, }); return { ...session, user: { id: user.id, name: user.name, email: user.email, status: user.status, }, recoveryCodesRemaining: recoveryCodeHashes.length, }; } async requestPasswordReset(email: string, ipAddress?: string) { const normalizedEmail = normalizeEmail(email); await this.assertRateLimit(PASSWORD_RESET_LIMITER, normalizedEmail, ipAddress); const user = await this.prisma.user.findUnique({ where: { email: normalizedEmail }, select: { id: true, name: true, email: true, status: true, }, }); if (!user || user.status !== 'active') { await this.registerFailedAttempt(PASSWORD_RESET_LIMITER, normalizedEmail, ipAddress); return { success: true }; } const token = this.generateToken(); const expiresAt = new Date(Date.now() + 60 * 60 * 1000); const tokenHash = this.hashToken(token); const resetUrl = `${config.frontendOrigin}/reset-password/${token}`; await this.prisma.user.update({ where: { id: user.id }, data: { passwordResetTokenHash: tokenHash, passwordResetTokenExpiresAt: expiresAt, }, }); try { await this.mailer.sendPasswordResetEmail({ to: user.email, name: user.name, resetUrl, }); } catch { // Keep the response generic to avoid account enumeration. } await this.registerFailedAttempt(PASSWORD_RESET_LIMITER, normalizedEmail, ipAddress); await this.prisma.auditLog.create({ data: { actorUserId: user.id, actorName: user.name, actorEmail: user.email, actionType: 'Password Reset Requested', module: 'Auth Gateway', ipAddress: ipAddress || null, severity: 'default', details: `Password reset requested for ${user.email}.`, }, }); await this.sendSecurityNotificationSafely({ to: user.email, name: user.name, subject: 'BizOne security alert: password reset completed', heading: 'Password Reset Completed', intro: 'Your BizOne admin password was changed successfully.', bullets: [ `Account: ${user.email}`, `Time: ${formatSecurityTimestamp(new Date())}`, `IP address: ${ipAddress || 'Unavailable'}`, ], note: 'If you did not complete this password reset, secure the account immediately and contact an administrator.', }); return { success: true }; } async getPasswordResetToken(token: string) { const user = await this.findPasswordResetUser(token); return { email: user.email, name: user.name, expiresAt: user.passwordResetTokenExpiresAt, }; } async completePasswordReset(token: string, password: string, ipAddress?: string) { if (!hasMinimumPasswordLength(password)) { throw new HttpException('Password must be at least 8 characters', HttpStatus.BAD_REQUEST); } const user = await this.findPasswordResetUser(token); const passwordHash = await hashPassword(password); await this.prisma.user.update({ where: { id: user.id }, data: { passwordHash, passwordResetTokenHash: null, passwordResetTokenExpiresAt: null, refreshTokenHash: null, refreshTokenExpiresAt: null, sessionVersion: { increment: 1, }, }, }); await this.prisma.auditLog.create({ data: { actorUserId: user.id, actorName: user.name, actorEmail: user.email, actionType: 'Password Reset Completed', module: 'Auth Gateway', ipAddress: ipAddress || null, severity: 'default', details: `Password reset completed for ${user.email}.`, }, }); return { success: true }; } private async assertRateLimit( limiter: { scope: string; maxAttempts: number; windowMinutes: number; message: string }, identity: string, ipAddress?: string, ) { const retryAfterMs = Math.max( await this.getRetryAfterMs(this.buildEmailKey(limiter.scope, identity), limiter.maxAttempts), ipAddress ? await this.getRetryAfterMs(this.buildIpKey(limiter.scope, ipAddress), limiter.maxAttempts) : 0, ); if (retryAfterMs > 0) { const retryAfterSeconds = Math.max(1, Math.ceil(retryAfterMs / 1000)); throw new HttpException( `${limiter.message} Try again in ${retryAfterSeconds} seconds.`, HttpStatus.TOO_MANY_REQUESTS, ); } } private async registerFailedAttempt( limiter: { scope: string; windowMinutes: number }, identity: string, ipAddress?: string, ) { await this.bumpAttempt(this.buildEmailKey(limiter.scope, identity), limiter.windowMinutes); if (ipAddress) { await this.bumpAttempt(this.buildIpKey(limiter.scope, ipAddress), limiter.windowMinutes); } } private async clearFailedAttempts( limiter: { scope: string }, identity: string, ipAddress?: string, ) { await this.redisQueueService.deleteKey(this.buildEmailKey(limiter.scope, identity)); if (ipAddress) { await this.redisQueueService.deleteKey(this.buildIpKey(limiter.scope, ipAddress)); } } private async bumpAttempt(key: string, windowMinutes: number) { await this.redisQueueService.incrementCounter(key, windowMinutes * 60); } private async getRetryAfterMs(key: string, maxAttempts: number) { const count = await this.redisQueueService.getCounter(key); if (count < maxAttempts) { return 0; } const ttlSeconds = await this.redisQueueService.getTtlSeconds(key); if (ttlSeconds <= 0) { return 0; } return ttlSeconds * 1000; } private buildEmailKey(scope: string, identity: string) { return `auth:${scope}:email:${identity}`; } private buildIpKey(scope: string, ipAddress: string) { return `auth:${scope}:ip:${ipAddress}`; } private async recordFailedLogin(input: { userId?: string; name?: string; email: string; ipAddress?: string; reason: string; }) { await this.prisma.auditLog.create({ data: { actorUserId: input.userId || null, actorName: input.name || input.email, actorEmail: input.email, actionType: 'Login Failed', module: 'Auth Gateway', ipAddress: input.ipAddress || null, severity: 'alert', details: `Failed login for ${input.email}. Reason: ${input.reason}.`, metadataJson: { reason: input.reason, email: input.email, ipAddress: input.ipAddress || null, } as Prisma.InputJsonValue, }, }); } private async findPasswordResetUser(token: string) { const user = await this.prisma.user.findFirst({ where: { passwordResetTokenHash: this.hashToken(token), }, select: { id: true, name: true, email: true, status: true, passwordResetTokenExpiresAt: true, }, }); if (!user || user.status !== 'active') { throw new UnauthorizedException('Invalid or expired reset link'); } if (!user.passwordResetTokenExpiresAt || user.passwordResetTokenExpiresAt.getTime() <= Date.now()) { throw new UnauthorizedException('Invalid or expired reset link'); } return user; } private async issueSession(payload: AuthenticatedUser, userId: string) { const access_token = await this.jwtService.signAsync(payload); const refresh_token = await this.jwtService.signAsync(payload, { secret: config.jwtRefreshSecret, expiresIn: config.jwtRefreshExpiresIn, }); const refreshTokenExpiresAt = new Date(Date.now() + this.durationToMs(config.jwtRefreshExpiresIn)); await this.prisma.user.update({ where: { id: userId }, data: { refreshTokenHash: this.hashToken(refresh_token), refreshTokenExpiresAt, }, }); return { access_token, refresh_token, expires_in: config.jwtExpiresIn, refresh_expires_in: config.jwtRefreshExpiresIn, access_token_max_age_seconds: Math.floor(this.durationToMs(config.jwtExpiresIn) / 1000), refresh_token_max_age_seconds: Math.floor(this.durationToMs(config.jwtRefreshExpiresIn) / 1000), }; } private async verifyRefreshToken(token: string) { return this.jwtService.verifyAsync(token, { secret: config.jwtRefreshSecret, }); } private hashToken(token: string) { return createHash('sha256').update(token).digest('hex'); } private generateToken() { return randomBytes(24).toString('hex'); } private async getTwoFactorRecord(userId: string): Promise { const rows = await this.prisma.$queryRaw( Prisma.sql` SELECT "two_factor_enabled" as "twoFactorEnabled", "two_factor_secret_encrypted" as "twoFactorSecretEncrypted", "two_factor_pending_secret_encrypted" as "twoFactorPendingSecretEncrypted", "two_factor_recovery_codes_hash_json" as "twoFactorRecoveryCodesHashJson", "two_factor_confirmed_at" as "twoFactorConfirmedAt" FROM "users" WHERE "id" = CAST(${userId} AS uuid) LIMIT 1 `, ); return rows[0] || null; } private durationToMs(value: string) { const normalized = value.trim().toLowerCase(); const match = normalized.match(/^(\d+)(s|m|h|d)$/); if (match) { const amount = Number(match[1]); const unit = match[2]; if (unit === 's') return amount * 1000; if (unit === 'm') return amount * 60 * 1000; if (unit === 'h') return amount * 60 * 60 * 1000; return amount * 24 * 60 * 60 * 1000; } const numeric = Number(normalized); if (Number.isFinite(numeric) && numeric > 0) { return numeric * 1000; } throw new Error(`Unsupported duration format: ${value}`); } private generateRecoveryCodes(count = 8) { return Array.from({ length: count }, () => randomBytes(4).toString('hex').toUpperCase().match(/.{1,4}/g)?.join('-') || ''); } private normalizeRecoveryCode(value: string) { return value.toUpperCase().replace(/[^A-Z0-9]/g, ''); } private hashRecoveryCode(value: string) { return createHash('sha256').update(this.normalizeRecoveryCode(value)).digest('hex'); } private readRecoveryCodeHashes(twoFactor: Pick) { const raw = twoFactor.twoFactorRecoveryCodesHashJson; if (!Array.isArray(raw)) { return []; } return raw.filter((value): value is string => typeof value === 'string' && value.length > 0); } private async sendSecurityNotificationSafely(input: { to: string; name: string; subject: string; heading: string; intro: string; bullets: string[]; note?: string; }) { try { await this.mailer.sendSecurityNotificationEmail(input); } catch { // Avoid breaking the auth flow if SMTP is down. } } }