import { BadRequestException, Injectable, Logger, UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import * as argon2 from 'argon2'; import { randomUUID } from 'crypto'; import { Response } from 'express'; import { MailService } from '../mail/mail.service'; import { PrismaService } from '../prisma/prisma.service'; @Injectable() export class AuthService { private readonly logger = new Logger(AuthService.name); constructor( private prisma: PrismaService, private jwtService: JwtService, private configService: ConfigService, private mailService: MailService, ) {} /** * Validate user credentials. Uses unscoped Prisma (no tenant context) * because login must work across all tenants. * * T-02-01: Returns null on any failure (never reveals which field is wrong). * Pitfall 6: Checks isActive to prevent deactivated users from logging in. */ async validateUser(username: string, password: string): Promise { const user = await this.prisma.user.findUnique({ where: { username }, }); if (!user || !user.isActive) { return null; } // LDAP users without local password cannot log in via local auth if (!user.passwordHash) { return null; } const isPasswordValid = await argon2.verify(user.passwordHash, password); if (!isPasswordValid) { return null; } // Update lastLoginAt await this.prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() }, }); return user; } /** * Issue JWT in httpOnly cookie and return user info. * D-02: 30-day session. * T-02-02: httpOnly + secure (prod) + sameSite=lax. */ async login(user: any, response: Response) { const payload = { sub: user.id, username: user.username, role: user.role, tenantId: user.tenantId, mustChangePassword: user.mustChangePassword, }; const token = this.jwtService.sign(payload); response.cookie('session', token, { httpOnly: true, secure: this.configService.get('NODE_ENV') === 'production', sameSite: 'lax', maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days path: '/', }); return { id: user.id, username: user.username, role: user.role, displayName: user.displayName, tenantId: user.tenantId, mustChangePassword: user.mustChangePassword, }; } /** * Clear the session cookie to log the user out. */ logout(response: Response) { response.clearCookie('session', { httpOnly: true, secure: this.configService.get('NODE_ENV') === 'production', sameSite: 'lax', path: '/', }); } /** * Request a password reset (D-03 self-service). * T-02-12: Always returns success, even if email not found (prevent enumeration). * T-02-13: Single-use token with 1-hour expiry. */ async requestPasswordReset(email: string): Promise { const user = await this.prisma.user.findUnique({ where: { email }, }); // Always return success to prevent email enumeration (T-02-12) if (!user || !user.isActive) { this.logger.log( `Password reset requested for unknown/inactive email: ${email}`, ); return; } // Generate a unique reset token const token = randomUUID(); const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour // Create the reset token record await this.prisma.passwordResetToken.create({ data: { token, userId: user.id, expiresAt, }, }); // Send the reset email (fire-and-forget, errors logged by MailService) await this.mailService.sendPasswordResetEmail(email, token); } /** * Reset password using a valid token (D-03 self-service). * T-02-13: Validates token not expired, not used. Marks as used after success. */ async resetPassword(token: string, newPassword: string): Promise { const resetToken = await this.prisma.passwordResetToken.findUnique({ where: { token }, include: { user: true }, }); if (!resetToken) { throw new BadRequestException('Invalid or expired reset token'); } // Check if token has already been used if (resetToken.usedAt) { throw new BadRequestException('Reset token has already been used'); } // Check if token has expired if (resetToken.expiresAt < new Date()) { throw new BadRequestException('Reset token has expired'); } // Hash the new password and update user const passwordHash = await argon2.hash(newPassword); await this.prisma.user.update({ where: { id: resetToken.userId }, data: { passwordHash, mustChangePassword: false, }, }); // Mark token as used (T-02-13) await this.prisma.passwordResetToken.update({ where: { id: resetToken.id }, data: { usedAt: new Date() }, }); this.logger.log(`Password reset completed for user ${resetToken.userId}`); } /** * Return enriched profile for the currently authenticated user. * T-gbh-03: Only public fields + isLocalUser/hasAvatar returned — never * passwordHash or ldapDn. */ async getMe(userId: string) { const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { id: true, username: true, displayName: true, role: true, tenantId: true, mustChangePassword: true, passwordHash: true, ldapDn: true, avatarPath: true, }, }); if (!user) { return null; } const { passwordHash, ldapDn, avatarPath, ...publicFields } = user; return { ...publicFields, isLocalUser: !!passwordHash && !ldapDn, hasAvatar: !!avatarPath, }; } /** * Change password for the currently logged-in user. * Verifies current password before allowing change. */ async changePassword( userId: string, currentPassword: string, newPassword: string, response: Response, ): Promise { const user = await this.prisma.user.findUnique({ where: { id: userId }, }); if (!user || !user.passwordHash) { throw new UnauthorizedException('User not found or has no local password'); } const isValid = await argon2.verify(user.passwordHash, currentPassword); if (!isValid) { throw new UnauthorizedException('Current password is incorrect'); } const passwordHash = await argon2.hash(newPassword); await this.prisma.user.update({ where: { id: userId }, data: { passwordHash, mustChangePassword: false }, }); const payload = { sub: user.id, username: user.username, role: user.role, tenantId: user.tenantId, mustChangePassword: false, }; const token = this.jwtService.sign(payload); (response as any).cookie('session', token, { httpOnly: true, secure: this.configService.get('NODE_ENV') === 'production', sameSite: 'lax', maxAge: 30 * 24 * 60 * 60 * 1000, path: '/', }); this.logger.log(`Password changed for user ${userId}`); } /** * Admin reset of a user's password (D-03 admin reset). * T-02-15: Only ADMIN/SUPER_ADMIN via RolesGuard. */ async adminResetPassword( userId: string, newPassword: string, mustChangePassword: boolean = true, ): Promise { const user = await this.prisma.user.findUnique({ where: { id: userId }, }); if (!user) { throw new BadRequestException('User not found'); } const passwordHash = await argon2.hash(newPassword); await this.prisma.user.update({ where: { id: userId }, data: { passwordHash, mustChangePassword, }, }); this.logger.log(`Admin reset password for user ${userId}`); } }