import { Injectable, UnauthorizedException, BadRequestException, Inject } from "@nestjs/common"; import { JwtService } from "@nestjs/jwt"; import { ConfigService } from "@nestjs/config"; import * as bcrypt from "bcrypt"; import { UsersService } from "@bff/modules/users/users.service"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service"; import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service"; import { AuditService, AuditAction } from "@bff/infra/audit/audit.service"; import { TokenBlacklistService } from "./services/token-blacklist.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; import { Logger } from "nestjs-pino"; import { sanitizeWhmcsRedirectPath } from "@bff/core/utils/sso.util"; import { type SignupRequestInput, type ValidateSignupRequestInput, type LinkWhmcsRequestInput, type SetPasswordRequestInput, } from "@customer-portal/domain"; import type { User as PrismaUser } from "@prisma/client"; import type { Request } from "express"; import { PrismaService } from "@bff/infra/database/prisma.service"; import { AuthTokenService } from "./services/token.service"; import { SignupWorkflowService } from "./services/workflows/signup-workflow.service"; import { PasswordWorkflowService } from "./services/workflows/password-workflow.service"; import { WhmcsLinkWorkflowService } from "./services/workflows/whmcs-link-workflow.service"; import { mapPrismaUserToUserProfile } from "@bff/infra/utils/user-mapper.util"; @Injectable() export class AuthService { private readonly MAX_LOGIN_ATTEMPTS = 5; private readonly LOCKOUT_DURATION_MINUTES = 15; constructor( private readonly usersService: UsersService, private readonly mappingsService: MappingsService, private readonly jwtService: JwtService, private readonly configService: ConfigService, private readonly whmcsService: WhmcsService, private readonly salesforceService: SalesforceService, private readonly auditService: AuditService, private readonly tokenBlacklistService: TokenBlacklistService, private readonly prisma: PrismaService, private readonly signupWorkflow: SignupWorkflowService, private readonly passwordWorkflow: PasswordWorkflowService, private readonly whmcsLinkWorkflow: WhmcsLinkWorkflowService, private readonly tokenService: AuthTokenService, @Inject(Logger) private readonly logger: Logger ) {} async healthCheck() { const health = { database: false, whmcs: false, salesforce: false, whmcsConfig: { baseUrl: !!this.configService.get("WHMCS_BASE_URL"), identifier: !!this.configService.get("WHMCS_API_IDENTIFIER"), secret: !!this.configService.get("WHMCS_API_SECRET"), }, salesforceConfig: { connected: false, }, }; // Check database try { await this.usersService.findByEmail("health-check@test.com"); health.database = true; } catch (error) { this.logger.debug("Database health check failed", { error: getErrorMessage(error) }); } // Check WHMCS try { health.whmcs = await this.whmcsService.healthCheck(); } catch (error) { this.logger.debug("WHMCS health check failed", { error: getErrorMessage(error) }); } // Check Salesforce try { health.salesforceConfig.connected = this.salesforceService.healthCheck(); health.salesforce = health.salesforceConfig.connected; } catch (error) { this.logger.debug("Salesforce health check failed", { error: getErrorMessage(error) }); } return { status: health.database && health.whmcs && health.salesforce ? "healthy" : "degraded", services: health, timestamp: new Date().toISOString(), }; } async validateSignup(validateData: ValidateSignupRequestInput, request?: Request) { return this.signupWorkflow.validateSignup(validateData, request); } async signup(signupData: SignupRequestInput, request?: Request) { return this.signupWorkflow.signup(signupData, request); } async login( user: { id: string; email: string; role?: string; passwordHash?: string | null; failedLoginAttempts?: number | null; lockedUntil?: Date | null; }, request?: Request ) { // Update last login time and reset failed attempts await this.usersService.update(user.id, { lastLoginAt: new Date(), failedLoginAttempts: 0, lockedUntil: null, }); // Log successful login await this.auditService.logAuthEvent( AuditAction.LOGIN_SUCCESS, user.id, { email: user.email }, request, true ); const prismaUser = await this.usersService.findByIdInternal(user.id); if (!prismaUser) { throw new UnauthorizedException("User record missing"); } const profile = mapPrismaUserToUserProfile(prismaUser); const tokens = await this.tokenService.generateTokenPair( { id: profile.id, email: profile.email, role: prismaUser.role, }, { userAgent: request?.headers["user-agent"], } ); return { user: { ...profile, role: prismaUser.role, }, tokens, }; } async linkWhmcsUser(linkData: LinkWhmcsRequestInput) { return this.whmcsLinkWorkflow.linkWhmcsUser(linkData.email, linkData.password); } async checkPasswordNeeded(email: string) { return this.passwordWorkflow.checkPasswordNeeded(email); } async setPassword(setPasswordData: SetPasswordRequestInput) { return this.passwordWorkflow.setPassword(setPasswordData.email, setPasswordData.password); } async validateUser( email: string, password: string, _request?: Request ): Promise<{ id: string; email: string; role: string } | null> { const user = await this.usersService.findByEmailInternal(email); if (!user) { await this.auditService.logAuthEvent( AuditAction.LOGIN_FAILED, undefined, { reason: "User not found" }, _request, false, "User not found" ); return null; } // Check if account is locked if (user.lockedUntil && user.lockedUntil > new Date()) { await this.auditService.logAuthEvent( AuditAction.LOGIN_FAILED, user.id, { reason: "Account locked" }, _request, false, "Account is locked" ); return null; } if (!user.passwordHash) { await this.auditService.logAuthEvent( AuditAction.LOGIN_FAILED, user.id, { reason: "No password set" }, _request, false, "No password set" ); return null; } try { const isPasswordValid = await bcrypt.compare(password, user.passwordHash); if (isPasswordValid) { // Return sanitized user object matching the return type return { id: user.id, email: user.email, role: user.role, }; } else { // Increment failed login attempts await this.handleFailedLogin(user, _request); return null; } } catch (error) { this.logger.error("Password validation error", { userId: user.id, error: getErrorMessage(error), }); await this.auditService.logAuthEvent( AuditAction.LOGIN_FAILED, user.id, { error: getErrorMessage(error) }, _request, false, getErrorMessage(error) ); return null; } } private async handleFailedLogin(user: PrismaUser, _request?: Request): Promise { const newFailedAttempts = (user.failedLoginAttempts || 0) + 1; let lockedUntil = null; let isAccountLocked = false; // Lock account if max attempts reached if (newFailedAttempts >= this.MAX_LOGIN_ATTEMPTS) { lockedUntil = new Date(); lockedUntil.setMinutes(lockedUntil.getMinutes() + this.LOCKOUT_DURATION_MINUTES); isAccountLocked = true; } await this.usersService.update(user.id, { failedLoginAttempts: newFailedAttempts, lockedUntil, }); // Log failed login await this.auditService.logAuthEvent( AuditAction.LOGIN_FAILED, user.id, { failedAttempts: newFailedAttempts, lockedUntil: lockedUntil?.toISOString(), }, _request, false, "Invalid password" ); // Log account lock if applicable if (isAccountLocked) { await this.auditService.logAuthEvent( AuditAction.ACCOUNT_LOCKED, user.id, { lockDuration: this.LOCKOUT_DURATION_MINUTES, lockedUntil: lockedUntil?.toISOString(), }, _request, false, `Account locked for ${this.LOCKOUT_DURATION_MINUTES} minutes` ); } } async logout(userId: string, token?: string, _request?: Request): Promise { if (token) { await this.tokenBlacklistService.blacklistToken(token); } try { await this.tokenService.revokeAllUserTokens(userId); } catch (error) { this.logger.warn("Failed to revoke refresh tokens during logout", { userId, error: getErrorMessage(error), }); } await this.auditService.logAuthEvent(AuditAction.LOGOUT, userId, {}, _request, true); } // Helper methods /** * Create SSO link to WHMCS for general access */ async createSsoLink( userId: string, destination?: string ): Promise<{ url: string; expiresAt: string }> { try { // Production-safe logging - no sensitive data this.logger.log("Creating SSO link request"); // Get WHMCS client ID from user mapping const mapping = await this.mappingsService.findByUserId(userId); if (!mapping?.whmcsClientId) { this.logger.warn("SSO link creation failed: No WHMCS mapping found"); throw new UnauthorizedException("WHMCS client mapping not found"); } // Create SSO token using custom redirect for better compatibility const ssoDestination = "sso:custom_redirect"; const ssoRedirectPath = this.sanitizeWhmcsRedirectPath(destination); const result = await this.whmcsService.createSsoToken( mapping.whmcsClientId, ssoDestination, ssoRedirectPath ); this.logger.log("SSO link created successfully"); return result; } catch (error) { // Production-safe error logging - no sensitive data or stack traces this.logger.error("SSO link creation failed", { errorType: error instanceof Error ? error.constructor.name : "Unknown", message: getErrorMessage(error), }); throw error; } } /** * Ensure only safe, relative WHMCS paths are allowed for SSO redirects. * Falls back to 'clientarea.php' when input is missing or unsafe. */ private sanitizeWhmcsRedirectPath(path?: string): string { return sanitizeWhmcsRedirectPath(path); } async requestPasswordReset(email: string): Promise { await this.passwordWorkflow.requestPasswordReset(email); } async resetPassword(token: string, newPassword: string) { return this.passwordWorkflow.resetPassword(token, newPassword); } async getAccountStatus(email: string) { // Normalize email const normalized = email?.toLowerCase().trim(); if (!normalized || !normalized.includes("@")) { throw new BadRequestException("Valid email is required"); } let portalUser: PrismaUser | null = null; let mapped = false; let whmcsExists = false; let needsPasswordSet = false; try { portalUser = await this.usersService.findByEmailInternal(normalized); if (portalUser) { mapped = await this.mappingsService.hasMapping(portalUser.id); needsPasswordSet = !portalUser.passwordHash; } } catch (e) { this.logger.warn("Account status: portal lookup failed", { error: getErrorMessage(e) }); } // If already mapped, we can assume a WHMCS client exists if (mapped) { whmcsExists = true; } else { // Try a direct WHMCS lookup by email (best-effort) try { const client = await this.whmcsService.getClientDetailsByEmail(normalized); whmcsExists = !!client; } catch (e) { // Treat not found as no; other errors as unknown (leave whmcsExists false) this.logger.debug("Account status: WHMCS lookup", { error: getErrorMessage(e) }); } } let state: "none" | "portal_only" | "whmcs_only" | "both_mapped" = "none"; if (portalUser && mapped) state = "both_mapped"; else if (portalUser) state = "portal_only"; else if (whmcsExists) state = "whmcs_only"; const recommendedAction = (() => { switch (state) { case "both_mapped": return "sign_in" as const; case "portal_only": return needsPasswordSet ? ("set_password" as const) : ("sign_in" as const); case "whmcs_only": return "link_account" as const; case "none": default: return "sign_up" as const; } })(); return { state, portalUserExists: !!portalUser, whmcsClientExists: whmcsExists, mapped, needsPasswordSet, recommendedAction, }; } async changePassword( userId: string, currentPassword: string, newPassword: string, request?: Request ) { return this.passwordWorkflow.changePassword(userId, currentPassword, newPassword, request); } async refreshTokens( refreshToken: string | undefined, deviceInfo?: { deviceId?: string; userAgent?: string } ) { if (!refreshToken) { throw new UnauthorizedException("Invalid refresh token"); } const { tokens, user } = await this.tokenService.refreshTokens(refreshToken, deviceInfo); return { user, tokens, }; } /** * Preflight validation for signup. No side effects. * Returns a clear nextAction for the UI and detailed flags. */ async signupPreflight(signupData: SignupRequestInput) { return this.signupWorkflow.signupPreflight(signupData); } }