import { Injectable, UnauthorizedException, ConflictException, Logger, NotFoundException, } from "@nestjs/common"; import { JwtService } from "@nestjs/jwt"; import { ConfigService } from "@nestjs/config"; import * as bcrypt from "bcrypt"; import * as crypto from "crypto"; import { PrismaService } from "../database/prisma.service"; import { RegisterDto } from "./dto/register.dto"; import { LoginDto } from "./dto/login.dto"; import { AuthResponseDto } from "./dto/auth-response.dto"; import { ForgotPasswordDto } from "./dto/forgot-password.dto"; import { ResetPasswordDto } from "./dto/reset-password.dto"; import { NotificationsService } from "../notifications/notifications.service"; import { EmailService } from "../email/email.service"; /** * Authentication Service * * CLINICAL SAFETY PRINCIPLES: * - Minimal PII storage * - No health data in auth tables * - No credential logging * - Secure password hashing (bcrypt) * - JWT-based stateless authentication * - Refresh token rotation for security */ @Injectable() export class AuthService { private readonly logger = new Logger(AuthService.name); private readonly SALT_ROUNDS = 10; constructor( private readonly prisma: PrismaService, private readonly jwtService: JwtService, private readonly configService: ConfigService, private readonly notificationsService: NotificationsService, private readonly emailService: EmailService, ) {} /** * Forgot password request */ async forgotPassword(forgotPasswordDto: ForgotPasswordDto): Promise { const { email, phone } = forgotPasswordDto; if (!email && !phone) { this.logger.warn("Forgot password attempt with no email or phone"); return; } // Find user — build conditions dynamically to avoid empty {} matching all rows const conditions: any[] = []; if (email) conditions.push({ email: { equals: email.toLowerCase(), mode: "insensitive" }, }); if (phone) conditions.push({ phone }); const user = await this.prisma.userAuth.findFirst({ where: conditions.length === 1 ? conditions[0] : { OR: conditions }, }); if (!user) { // Don't reveal if user exists for security this.logger.warn( `Forgot password attempt for non-existent user: ${email || phone}`, ); return; } // Generate token (valid for 1 hour) const token = crypto.randomBytes(32).toString("hex"); const expires = new Date(); expires.setHours(expires.getHours() + 1); // Save token await this.prisma.userAuth.update({ where: { id: user.id }, data: { passwordResetToken: token, passwordResetExpiresAt: expires, }, }); // Send reset message (via notification service) await this.notificationsService.sendResetPasswordNotification( user.id, token, ); this.logger.log(`Password reset token generated for user: ${user.id}`); } /** * Reset password with token */ async resetPassword(resetPasswordDto: ResetPasswordDto): Promise { const { token, newPassword } = resetPasswordDto; // Find user with valid token const user = await this.prisma.userAuth.findFirst({ where: { passwordResetToken: token, passwordResetExpiresAt: { gt: new Date() }, }, }); if (!user) { throw new UnauthorizedException("Invalid or expired reset token"); } // Hash new password const passwordHash = await bcrypt.hash(newPassword, this.SALT_ROUNDS); // Update user await this.prisma.userAuth.update({ where: { id: user.id }, data: { passwordHash, passwordResetToken: null, passwordResetExpiresAt: null, refreshToken: null, // Force logout from all devices status: "ACTIVE", }, }); this.logger.log(`Password reset successful for user: ${user.id}`); } /** * Setup initial password for new users */ async setupPassword(token: string, password: string): Promise { // Find user with valid setup token const user = await this.prisma.userAuth.findFirst({ where: { passwordSetupToken: token, passwordSetupExpiresAt: { gt: new Date() }, }, }); if (!user) { throw new UnauthorizedException("Invalid or expired setup token"); } // Hash new password const passwordHash = await bcrypt.hash(password, this.SALT_ROUNDS); // Update user await this.prisma.userAuth.update({ where: { id: user.id }, data: { passwordHash, passwordSetupToken: null, passwordSetupExpiresAt: null, status: "ACTIVE", }, }); this.logger.log(`Password setup successful for user: ${user.id}`); } /** * Register a new user * * CONSTRAINTS: * - Email OR phone required (not both) * - Password must be at least 8 characters * - No duplicate email/phone */ async register(registerDto: RegisterDto): Promise { this.logger.log( `Registration attempt for: ${registerDto.email || registerDto.phone}`, ); const { email, phone, password } = registerDto; // Validate that at least one identifier is provided if (!email && !phone) { throw new ConflictException("Either email or phone must be provided"); } // Check for existing user — build conditions dynamically const conditions: any[] = []; const normalizedEmail = email?.toLowerCase(); if (normalizedEmail) conditions.push({ email: { equals: normalizedEmail, mode: "insensitive" }, }); if (phone) conditions.push({ phone }); const existingUser = await this.prisma.userAuth.findFirst({ where: conditions.length === 1 ? conditions[0] : { OR: conditions }, }); if (existingUser) { throw new ConflictException( "User with this email or phone already exists", ); } // Hash password (NEVER log the password) const passwordHash = await bcrypt.hash(password, this.SALT_ROUNDS); // Create user try { const user = await this.prisma.userAuth.create({ data: { email: normalizedEmail, phone, passwordHash, status: "ACTIVE", // Patients register themselves, so set to active }, }); this.logger.log(`New user registered: ${user.id}`); // TRIGGER 2 — Send welcome email asynchronously if (user.email) { this.emailService .sendWelcomeEmail(user.email, "Mama") .catch((err) => this.logger.error(`Failed to send welcome email to ${user.email}`, err), ); } return this.generateTokens(user.id); } catch (error: any) { this.logger.error( `Database error during registration: ${error.message}`, error.stack, ); throw error; } } /** * Login user * * CONSTRAINTS: * - Email OR phone required * - Password verification * - No credential logging */ async login(loginDto: LoginDto): Promise { const { email, phone, password } = loginDto; // Validate that at least one identifier is provided if (!email && !phone) { throw new UnauthorizedException("Either email or phone must be provided"); } // Find user — build conditions dynamically const loginConditions: any[] = []; if (email) loginConditions.push({ email: { equals: email.toLowerCase(), mode: "insensitive" }, }); if (phone) loginConditions.push({ phone }); const user = await this.prisma.userAuth.findFirst({ where: loginConditions.length === 1 ? loginConditions[0] : { OR: loginConditions }, }); if (!user) { // Generic error to prevent user enumeration throw new UnauthorizedException("Invalid credentials"); } // Check user status if (user.status === "PENDING") { throw new UnauthorizedException( "Your account has not been activated. Please check your email to set your password.", ); } if (user.status === "SUSPENDED") { throw new UnauthorizedException("Your account has been suspended."); } // Check if password is set if (!user.passwordHash) { throw new UnauthorizedException("Please set your password first."); } // Verify password (NEVER log the password) const isPasswordValid = await bcrypt.compare(password, user.passwordHash); if (!isPasswordValid) { throw new UnauthorizedException("Invalid credentials"); } // Check if user is active if (!user.isActive) { throw new UnauthorizedException("Account is inactive"); } this.logger.log(`User logged in: ${user.id}`); // Generate tokens return this.generateTokens(user.id); } /** * Refresh access token */ async refreshToken(refreshToken: string): Promise { try { // Verify refresh token const payload = this.jwtService.verify(refreshToken, { secret: this.configService.get("JWT_REFRESH_SECRET"), }); // Find user const user = await this.prisma.userAuth.findUnique({ where: { id: payload.sub }, }); if (!user || !user.isActive) { throw new UnauthorizedException("Invalid refresh token"); } // Verify stored refresh token matches if (user.refreshToken !== refreshToken) { throw new UnauthorizedException("Invalid refresh token"); } // Generate new tokens return this.generateTokens(user.id); } catch (error) { throw new UnauthorizedException("Invalid refresh token"); } } /** * Logout user by invalidating stored refresh token * * SECURITY: * - Clears refresh token from database so it can no longer be used */ async logout(userId: string): Promise { await this.prisma.userAuth.update({ where: { id: userId }, data: { refreshToken: null, pushToken: null, // Clear push token on logout for security }, }); this.logger.log(`User logged out: ${userId}`); } /** * Generate JWT access and refresh tokens * * SECURITY: * - Access token: short-lived (1 hour) * - Refresh token: longer-lived (7 days) * - Refresh token stored in database for rotation */ private async generateTokens(userId: string): Promise { // Get user data first const userData = await this.prisma.userAuth.findUnique({ where: { id: userId }, select: { id: true, email: true, role: true, healthcareWorker: { select: { id: true, facilityId: true, type: true, isSuspended: true, }, }, patient: { select: { id: true, facilityId: true, }, }, }, }); // Build JWT payload const payload: any = { sub: userId, id: userData?.id, email: userData?.email, role: userData?.role, }; if (userData?.healthcareWorker) { payload.healthcareWorker = userData.healthcareWorker; } if (userData?.patient) { payload.patient = userData.patient; } // Generate access token const accessToken = this.jwtService.sign(payload, { secret: this.configService.get("JWT_SECRET"), expiresIn: this.configService.get("JWT_EXPIRATION"), }); // Generate refresh token const refreshToken = this.jwtService.sign({ sub: userId }, { secret: this.configService.get("JWT_REFRESH_SECRET"), expiresIn: this.configService.get("JWT_REFRESH_EXPIRATION"), }); // Store refresh token in database and get full user const user = await this.prisma.userAuth.update({ where: { id: userId }, data: { refreshToken }, select: { id: true, email: true, phone: true, isActive: true, role: true, healthcareWorker: { select: { id: true, firstName: true, lastName: true, type: true, facilityId: true, isSuspended: true, }, }, patient: { select: { id: true, firstName: true, lastName: true, phone: true, facilityId: true, patientId: true, }, }, }, }); return { accessToken, refreshToken, userId, user, }; } /** * Validate user by ID (used by JWT strategy) */ async validateUser(userId: string) { const user = await this.prisma.userAuth.findUnique({ where: { id: userId }, select: { id: true, email: true, phone: true, isActive: true, role: true, healthcareWorker: { select: { id: true, firstName: true, lastName: true, type: true, facilityId: true, isSuspended: true, }, }, patient: { select: { id: true, firstName: true, lastName: true, phone: true, facilityId: true, patientId: true, }, }, }, }); if (!user || !user?.isActive) { return null; } return user; } }