Files
BizOne-portal/backend/src/auth/auth.service.ts

1198 lines
35 KiB
TypeScript

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<AuthenticatedUser>(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<TwoFactorChallengePayload>(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<AuthenticatedUser>(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<TwoFactorRecord | null> {
const rows = await this.prisma.$queryRaw<TwoFactorRecord[]>(
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<TwoFactorRecord, 'twoFactorRecoveryCodesHashJson'>) {
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.
}
}
}