1198 lines
35 KiB
TypeScript
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.
|
|
}
|
|
}
|
|
}
|