import { Injectable, UnauthorizedException, ConflictException, BadRequestException, Logger, } from "@nestjs/common"; import { JwtService } from "@nestjs/jwt"; import { ConfigService } from "@nestjs/config"; import * as bcrypt from "bcrypt"; import { UsersService } from "../users/users.service"; import { MappingsService } from "../mappings/mappings.service"; import { WhmcsService } from "../vendors/whmcs/whmcs.service"; import { SalesforceService } from "../vendors/salesforce/salesforce.service"; import { AuditService, AuditAction } from "../common/audit/audit.service"; import { TokenBlacklistService } from "./services/token-blacklist.service"; import { SignupDto } from "./dto/signup.dto"; import { LinkWhmcsDto } from "./dto/link-whmcs.dto"; import { SetPasswordDto } from "./dto/set-password.dto"; import { getErrorMessage } from "../common/utils/error.util"; @Injectable() export class AuthService { private readonly logger = new Logger(AuthService.name); private readonly MAX_LOGIN_ATTEMPTS = 5; private readonly LOCKOUT_DURATION_MINUTES = 15; constructor( private usersService: UsersService, private mappingsService: MappingsService, private jwtService: JwtService, private configService: ConfigService, private whmcsService: WhmcsService, private salesforceService: SalesforceService, private auditService: AuditService, private tokenBlacklistService: TokenBlacklistService, ) {} async signup(signupData: SignupDto, request?: any) { const { email, password, firstName, lastName, company, phone } = signupData; // Check if user already exists const existingUser = await this.usersService.findByEmailInternal(email); if (existingUser) { await this.auditService.logAuthEvent( AuditAction.SIGNUP, undefined, { email, reason: "User already exists" }, request, false, "User with this email already exists", ); throw new ConflictException("User with this email already exists"); } // Hash password const saltRounds = 12; // Use a fixed safe value const passwordHash = await bcrypt.hash(password, saltRounds); try { // 1. Create user in portal const user = await this.usersService.create({ email, passwordHash, firstName, lastName, company, phone, emailVerified: false, failedLoginAttempts: 0, lockedUntil: null, lastLoginAt: null, }); // 2. Create client in WHMCS const whmcsClient = await this.whmcsService.addClient({ firstname: firstName, lastname: lastName, email, companyname: company || "", phonenumber: phone || "", password2: password, // WHMCS requires plain password for new clients }); // 3. Create account in Salesforce (no Contact, just Account) const sfAccount = await this.salesforceService.upsertAccount({ name: company || `${firstName} ${lastName}`, phone: phone, }); // 4. Store ID mappings await this.mappingsService.createMapping({ userId: user.id, whmcsClientId: whmcsClient.clientId, sfAccountId: sfAccount.id, }); // Log successful signup await this.auditService.logAuthEvent( AuditAction.SIGNUP, user.id, { email, whmcsClientId: whmcsClient.clientId }, request, true, ); // Generate JWT token const tokens = await this.generateTokens(user); return { user: this.sanitizeUser(user), ...tokens, }; } catch (error) { // Log failed signup await this.auditService.logAuthEvent( AuditAction.SIGNUP, undefined, { email, error: getErrorMessage(error) }, request, false, getErrorMessage(error), ); // TODO: Implement rollback logic if any step fails this.logger.error("Signup error:", error); throw new BadRequestException("Failed to create user account"); } } async login(user: any, request?: any) { // 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 tokens = await this.generateTokens(user); return { user: this.sanitizeUser(user), ...tokens, }; } async linkWhmcsUser(linkData: LinkWhmcsDto, request?: any) { const { email, password } = linkData; // Check if user already exists in portal const existingUser = await this.usersService.findByEmailInternal(email); if (existingUser) { // If user exists but has no password (abandoned during setup), allow them to continue if (!existingUser.passwordHash) { this.logger.log( `User ${email} exists but has no password - allowing password setup to continue`, ); return { user: this.sanitizeUser(existingUser), needsPasswordSet: true, }; } else { throw new ConflictException( "User already exists in portal and has completed setup. Please use the login page.", ); } } try { // 1. First, find the client by email using GetClientsDetails directly let clientDetails; try { clientDetails = await this.whmcsService.getClientDetailsByEmail(email); } catch (error) { throw new UnauthorizedException( "WHMCS client not found with this email address", ); } // 2. Validate the password using ValidateLogin try { this.logger.debug(`About to validate WHMCS password for ${email}`); const validateResult = await this.whmcsService.validateLogin( email, password, ); this.logger.debug( `WHMCS validation successful for ${email}:`, validateResult, ); if (!validateResult || !validateResult.userId) { throw new UnauthorizedException("Invalid WHMCS credentials"); } } catch (error) { this.logger.debug( `WHMCS validation failed for ${email}:`, getErrorMessage(error), ); throw new UnauthorizedException("Invalid WHMCS password"); } // 3. Extract Customer Number from field ID 198 const customerNumberField = clientDetails.customfields?.find( (field: any) => field.id == 198, // Use == instead of === to handle string/number comparison ); const customerNumber = customerNumberField?.value; if (!customerNumber || customerNumber.toString().trim() === "") { throw new BadRequestException( `Customer Number not found in WHMCS custom field 198. ` + `Found field: ${JSON.stringify(customerNumberField)}. ` + `Available custom fields: ${JSON.stringify(clientDetails.customfields || [])}. ` + `Please contact support.`, ); } this.logger.log( `Found Customer Number: ${customerNumber} for WHMCS client ${clientDetails.id}`, ); // 3. Find existing Salesforce account using Customer Number const sfAccount = await this.salesforceService.findAccountByCustomerNumber( customerNumber, ); if (!sfAccount) { throw new BadRequestException( `Salesforce account not found for Customer Number: ${customerNumber}. Please contact support.`, ); } // 4. Create portal user (without password initially) const user = await this.usersService.create({ email, passwordHash: null, // No password hash - will be set when user sets password firstName: clientDetails.firstname || "", lastName: clientDetails.lastname || "", company: clientDetails.companyname || "", phone: clientDetails.phonenumber || "", emailVerified: true, // WHMCS users are pre-verified }); // 5. Store ID mappings await this.mappingsService.createMapping({ userId: user.id, whmcsClientId: parseInt(clientDetails.id), sfAccountId: sfAccount.id, }); return { user: this.sanitizeUser(user), needsPasswordSet: true, }; } catch (error) { this.logger.error("WHMCS linking error:", error); if ( error instanceof BadRequestException || error instanceof UnauthorizedException ) { throw error; } throw new BadRequestException("Failed to link WHMCS account"); } } async checkPasswordNeeded(email: string) { const user = await this.usersService.findByEmailInternal(email); if (!user) { return { needsPasswordSet: false, userExists: false }; } return { needsPasswordSet: !user.passwordHash, userExists: true, email: user.email, }; } async setPassword(setPasswordData: SetPasswordDto, request?: any) { const { email, password } = setPasswordData; const user = await this.usersService.findByEmailInternal(email); if (!user) { throw new UnauthorizedException("User not found"); } // Check if user needs to set password (linked users have null password hash) if (user.passwordHash) { throw new BadRequestException("User already has a password set"); } // Hash new password const saltRounds = 12; // Use a fixed safe value const passwordHash = await bcrypt.hash(password, saltRounds); // Update user with new password const updatedUser = await this.usersService.update(user.id, { passwordHash, }); // Generate tokens const tokens = await this.generateTokens(updatedUser); return { user: this.sanitizeUser(updatedUser), ...tokens, }; } async validateUser( email: string, password: string, request?: any, ): Promise { const user = await this.usersService.findByEmailInternal(email); if (!user) { await this.auditService.logAuthEvent( AuditAction.LOGIN_FAILED, undefined, { email, 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, { email, reason: "Account locked" }, request, false, "Account is locked", ); return null; } if (!user.passwordHash) { await this.auditService.logAuthEvent( AuditAction.LOGIN_FAILED, user.id, { email, reason: "No password set" }, request, false, "No password set", ); return null; } try { const isPasswordValid = await bcrypt.compare(password, user.passwordHash); if (isPasswordValid) { return user; } else { // Increment failed login attempts await this.handleFailedLogin(user, request); return null; } } catch (error) { this.logger.error( `Password validation error for ${email}:`, getErrorMessage(error), ); await this.auditService.logAuthEvent( AuditAction.LOGIN_FAILED, user.id, { email, error: getErrorMessage(error) }, request, false, getErrorMessage(error), ); return null; } } private async handleFailedLogin(user: any, request?: any): 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, { email: user.email, 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, { email: user.email, 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?: any): Promise { // Blacklist the token await this.tokenBlacklistService.blacklistToken(token); await this.auditService.logAuthEvent( AuditAction.LOGOUT, userId, {}, request, true, ); } // Helper methods private async generateTokens(user: any) { const payload = { email: user.email, sub: user.id, role: user.role }; return { access_token: this.jwtService.sign(payload), }; } private sanitizeUser(user: any) { const { passwordHash, failedLoginAttempts, lockedUntil, ...sanitizedUser } = user; return sanitizedUser; } /** * 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 let ssoDestination = "sso:custom_redirect"; let ssoRedirectPath = destination || "clientarea.php"; 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; } } }