Spaces:
Running
Running
| 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 | |
| */ | |
| () | |
| 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<void> { | |
| 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<void> { | |
| 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<void> { | |
| // 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<AuthResponseDto> { | |
| 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<AuthResponseDto> { | |
| 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<AuthResponseDto> { | |
| 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<void> { | |
| 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<AuthResponseDto> { | |
| // 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; | |
| } | |
| } | |