import { BadRequestException, Injectable, NotFoundException, UnauthorizedException, } from "@nestjs/common"; import * as crypto from "crypto"; import * as speakeasy from "speakeasy"; import { JwtService } from "@nestjs/jwt"; import { PrismaService } from "../prisma/prisma.service"; import { EmailService } from "../email/email.service"; import { EncryptionService } from "../common/encryption.service"; import { LoginDto } from "./dto/login.dto"; import { RegisterDto } from "./dto/register.dto"; import { UpdateProfileDto } from "./dto/update-profile.dto"; import { ForgotPasswordDto } from "./dto/forgot-password.dto"; import { ResetPasswordDto } from "./dto/reset-password.dto"; const VERIFY_TOKEN_TTL_HOURS = 24; const RESET_TOKEN_TTL_HOURS = 1; const REFRESH_TOKEN_TTL_DAYS = 30; @Injectable() export class AuthService { constructor( private readonly prisma: PrismaService, private readonly jwtService: JwtService, private readonly emailService: EmailService, private readonly encryption: EncryptionService, ) {} async register(payload: RegisterDto) { const email = payload.email.toLowerCase().trim(); const existing = await this.prisma.user.findUnique({ where: { email } }); if (existing) throw new BadRequestException("Email already registered."); const passwordHash = this.hashPassword(payload.password); const user = await this.prisma.user.create({ data: { email, passwordHash } }); await this.prisma.auditLog.create({ data: { userId: user.id, action: "auth.register", metadata: { email } } }); const verifyToken = crypto.randomBytes(32).toString("hex"); const expiresAt = new Date(Date.now() + VERIFY_TOKEN_TTL_HOURS * 3600 * 1000); await this.prisma.emailVerificationToken.upsert({ where: { userId: user.id }, update: { token: verifyToken, expiresAt }, create: { userId: user.id, token: verifyToken, expiresAt }, }); await this.emailService.sendVerificationEmail(email, verifyToken); const accessToken = this.signAccessToken(user.id); const refreshToken = await this.createRefreshToken(user.id); return { user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified }, accessToken, refreshToken, message: "Registration successful. Please verify your email.", }; } async login(payload: LoginDto) { const email = payload.email.toLowerCase().trim(); const user = await this.prisma.user.findUnique({ where: { email } }); if (!user || !this.verifyPassword(payload.password, user.passwordHash)) { throw new UnauthorizedException("Invalid credentials."); } // ── 2FA enforcement ────────────────────────────────────────────────────── if (user.twoFactorEnabled && user.twoFactorSecret) { if (!payload.totpToken) { return { requiresTwoFactor: true, accessToken: null, refreshToken: null }; } const secret = this.encryption.decrypt(user.twoFactorSecret); const isValid = speakeasy.totp.verify({ secret, encoding: "base32", token: payload.totpToken, window: 1 }); if (!isValid) { throw new UnauthorizedException("Invalid TOTP code."); } } await this.prisma.auditLog.create({ data: { userId: user.id, action: "auth.login", metadata: { email } } }); const accessToken = this.signAccessToken(user.id); const refreshToken = await this.createRefreshToken(user.id); return { user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified }, accessToken, refreshToken, }; } async verifyEmail(token: string) { const record = await this.prisma.emailVerificationToken.findUnique({ where: { token } }); if (!record || record.expiresAt < new Date()) { throw new BadRequestException("Invalid or expired verification token."); } await this.prisma.user.update({ where: { id: record.userId }, data: { emailVerified: true } }); await this.prisma.emailVerificationToken.delete({ where: { token } }); return { message: "Email verified successfully." }; } async refreshAccessToken(rawRefreshToken: string) { const tokenHash = this.hashToken(rawRefreshToken); const record = await this.prisma.refreshToken.findUnique({ where: { tokenHash } }); if (!record || record.revokedAt || record.expiresAt < new Date()) { throw new UnauthorizedException("Invalid or expired refresh token."); } await this.prisma.refreshToken.update({ where: { id: record.id }, data: { revokedAt: new Date() } }); const accessToken = this.signAccessToken(record.userId); const refreshToken = await this.createRefreshToken(record.userId); return { accessToken, refreshToken }; } async logout(rawRefreshToken: string) { const tokenHash = this.hashToken(rawRefreshToken); await this.prisma.refreshToken.updateMany({ where: { tokenHash, revokedAt: null }, data: { revokedAt: new Date() }, }); return { message: "Logged out." }; } async forgotPassword(payload: ForgotPasswordDto) { const email = payload.email.toLowerCase().trim(); const user = await this.prisma.user.findUnique({ where: { email } }); if (!user) return { message: "If that email exists, a reset link has been sent." }; const token = crypto.randomBytes(32).toString("hex"); const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_HOURS * 3600 * 1000); await this.prisma.passwordResetToken.create({ data: { userId: user.id, token, expiresAt } }); await this.emailService.sendPasswordResetEmail(email, token); return { message: "If that email exists, a reset link has been sent." }; } async resetPassword(payload: ResetPasswordDto) { const record = await this.prisma.passwordResetToken.findUnique({ where: { token: payload.token } }); if (!record || record.usedAt || record.expiresAt < new Date()) { throw new BadRequestException("Invalid or expired reset token."); } const passwordHash = this.hashPassword(payload.password); await this.prisma.user.update({ where: { id: record.userId }, data: { passwordHash } }); await this.prisma.passwordResetToken.update({ where: { id: record.id }, data: { usedAt: new Date() } }); await this.prisma.refreshToken.updateMany({ where: { userId: record.userId, revokedAt: null }, data: { revokedAt: new Date() }, }); return { message: "Password reset successfully. Please log in." }; } async updateProfile(userId: string, payload: UpdateProfileDto) { const data: Record = {}; for (const [key, value] of Object.entries(payload)) { const trimmed = (value as string | undefined)?.trim(); if (trimmed) data[key] = trimmed; } if (!Object.keys(data).length) throw new BadRequestException("No profile fields provided."); const user = await this.prisma.user.update({ where: { id: userId }, data }); await this.prisma.auditLog.create({ data: { userId: user.id, action: "auth.profile.update", metadata: { updatedFields: Object.keys(data) } }, }); return { user: { id: user.id, email: user.email, fullName: user.fullName, phone: user.phone, companyName: user.companyName, addressLine1: user.addressLine1, addressLine2: user.addressLine2, city: user.city, state: user.state, postalCode: user.postalCode, country: user.country, }, }; } async getProfile(userId: string) { const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user) throw new NotFoundException("User not found."); return { user: { id: user.id, email: user.email, fullName: user.fullName, phone: user.phone, companyName: user.companyName, addressLine1: user.addressLine1, addressLine2: user.addressLine2, city: user.city, state: user.state, postalCode: user.postalCode, country: user.country, emailVerified: user.emailVerified, twoFactorEnabled: user.twoFactorEnabled, createdAt: user.createdAt, }, }; } verifyToken(token: string): { sub: string } { return this.jwtService.verify<{ sub: string }>(token); } private signAccessToken(userId: string): string { return this.jwtService.sign({ sub: userId }); } private async createRefreshToken(userId: string): Promise { const raw = crypto.randomBytes(40).toString("hex"); const tokenHash = this.hashToken(raw); const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_DAYS * 86400 * 1000); await this.prisma.refreshToken.create({ data: { userId, tokenHash, expiresAt } }); return raw; } private hashToken(token: string): string { return crypto.createHash("sha256").update(token).digest("hex"); } private hashPassword(password: string): string { const salt = crypto.randomBytes(16).toString("hex"); const hash = crypto.pbkdf2Sync(password, salt, 100_000, 64, "sha512").toString("hex"); return `${salt}:${hash}`; } private verifyPassword(password: string, stored: string): boolean { const [salt, hash] = stored.split(":"); if (!salt || !hash) return false; const computed = crypto.pbkdf2Sync(password, salt, 100_000, 64, "sha512").toString("hex"); return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(computed)); } }