import { Injectable, UnauthorizedException, ConflictException, BadRequestException, Inject, } 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"; import { Logger } from "nestjs-pino"; import { EmailService } from "../common/email/email.service"; import { User as SharedUser } from "@customer-portal/shared"; import type { User as PrismaUser } from "@prisma/client"; import type { Request } from "express"; import { WhmcsClientResponse } from "../vendors/whmcs/types/whmcs-api.types"; @Injectable() export class AuthService { 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, private emailService: EmailService, @Inject(Logger) private readonly logger: Logger ) {} async signup(signupData: SignupDto, request?: Request) { const { email, password, firstName, lastName, company, phone, sfNumber, address, nationality, dateOfBirth, gender, } = signupData; // Enhanced input validation this.validateSignupData(signupData); // Check if user already exists const existingUser: PrismaUser | null = 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 with environment-based configuration const saltRoundsConfig = this.configService.get("BCRYPT_ROUNDS", 12); const saltRounds = typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig; const passwordHash = await bcrypt.hash(password, saltRounds); try { // 0. Lookup Salesforce Account by Customer Number (SF Number) const sfAccount: { id: string } | null = await this.salesforceService.findAccountByCustomerNumber(sfNumber); if (!sfAccount) { throw new BadRequestException( `Salesforce account not found for Customer Number: ${sfNumber}` ); } // 1. Create user in portal const user: SharedUser = await this.usersService.create({ email, passwordHash, firstName, lastName, company, phone, emailVerified: false, failedLoginAttempts: 0, lockedUntil: null, lastLoginAt: null, }); // 2. Create client in WHMCS // Prepare WHMCS custom fields (IDs configurable via env) const customerNumberFieldId = this.configService.get( "WHMCS_CUSTOMER_NUMBER_FIELD_ID", "198" ); const dobFieldId = this.configService.get("WHMCS_DOB_FIELD_ID"); const genderFieldId = this.configService.get("WHMCS_GENDER_FIELD_ID"); const nationalityFieldId = this.configService.get("WHMCS_NATIONALITY_FIELD_ID"); const customfields: Record = {}; if (customerNumberFieldId) customfields[customerNumberFieldId] = sfNumber; if (dobFieldId && dateOfBirth) customfields[dobFieldId] = dateOfBirth; if (genderFieldId && gender) customfields[genderFieldId] = gender; if (nationalityFieldId && nationality) customfields[nationalityFieldId] = nationality; const whmcsClient: { clientId: number } = await this.whmcsService.addClient({ firstname: firstName, lastname: lastName, email, companyname: company || "", phonenumber: phone || "", address1: address?.line1, city: address?.city, state: address?.state, postcode: address?.postalCode, country: address?.country, password2: password, // WHMCS requires plain password for new clients customfields, }); // 3. 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 = 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: getErrorMessage(error) }); throw new BadRequestException("Failed to create user account"); } } 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 tokens = this.generateTokens(user); return { user: this.sanitizeUser(user), ...tokens, }; } async linkWhmcsUser(linkData: LinkWhmcsDto, _request?: Request) { const { email, password } = linkData; // Check if user already exists in portal const existingUser: PrismaUser | null = 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: WhmcsClientResponse["client"]; try { clientDetails = await this.whmcsService.getClientDetailsByEmail(email); } catch (error) { this.logger.warn(`WHMCS client lookup failed for email ${email}`, { error: getErrorMessage(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", { email }); if (!validateResult || !validateResult.userId) { throw new UnauthorizedException("Invalid WHMCS credentials"); } } catch (error) { this.logger.debug("WHMCS validation failed", { email, error: getErrorMessage(error) }); throw new UnauthorizedException("Invalid WHMCS password"); } // 3. Extract Customer Number from field ID 198 const customerNumberField = clientDetails.customfields?.find(field => field.id === 198); const customerNumber = customerNumberField?.value?.trim(); if (!customerNumber) { throw new BadRequestException( `Customer Number not found in WHMCS custom field 198. 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: { id: string } | null = 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: SharedUser = 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: clientDetails.id, sfAccountId: sfAccount.id, }); return { user: this.sanitizeUser(user), needsPasswordSet: true, }; } catch (error) { this.logger.error("WHMCS linking error", { error: getErrorMessage(error) }); if (error instanceof BadRequestException || error instanceof UnauthorizedException) { throw error; } throw new BadRequestException("Failed to link WHMCS account"); } } async checkPasswordNeeded(email: string) { const user: PrismaUser | null = 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?: Request) { const { email, password } = setPasswordData; const user: PrismaUser | null = 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: SharedUser = await this.usersService.update(user.id, { passwordHash, }); // Generate tokens const tokens = this.generateTokens(updatedUser); return { user: this.sanitizeUser(updatedUser), ...tokens, }; } async validateUser( email: string, password: string, _request?: Request ): Promise { const user: PrismaUser | null = 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", { email, error: 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: 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, { 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?: Request): Promise { // Blacklist the token await this.tokenBlacklistService.blacklistToken(token); await this.auditService.logAuthEvent(AuditAction.LOGOUT, userId, {}, _request, true); } // Helper methods private generateTokens(user: { id: string; email: string; role?: string }) { const payload = { email: user.email, sub: user.id, role: user.role }; return { access_token: this.jwtService.sign(payload), }; } private sanitizeUser< T extends { id: string; email: string; role?: string; passwordHash?: string | null; failedLoginAttempts?: number | null; lockedUntil?: Date | null; }, >(user: T): Omit { const { passwordHash: _passwordHash, failedLoginAttempts: _failedLoginAttempts, lockedUntil: _lockedUntil, ...sanitizedUser } = user as T & { passwordHash?: string | null; failedLoginAttempts?: number | null; lockedUntil?: Date | null; }; 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 const ssoDestination = "sso:custom_redirect"; const 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; } } async requestPasswordReset(email: string): Promise { const user = await this.usersService.findByEmailInternal(email); // Always act as if successful to avoid account enumeration if (!user) { return; } // Create a short-lived signed token (JWT) containing user id and purpose const token = this.jwtService.sign( { sub: user.id, purpose: "password_reset" }, { expiresIn: "15m" } ); const appBase = this.configService.get("APP_BASE_URL", "http://localhost:3000"); const resetUrl = `${appBase}/auth/reset-password?token=${encodeURIComponent(token)}`; const templateId = this.configService.get("EMAIL_TEMPLATE_RESET"); if (templateId) { await this.emailService.sendEmail({ to: email, subject: "Reset your password", templateId, dynamicTemplateData: { resetUrl }, }); } else { await this.emailService.sendEmail({ to: email, subject: "Reset your Assist Solutions password", html: `

We received a request to reset your password.

Click here to reset your password. This link expires in 15 minutes.

If you didn't request this, you can safely ignore this email.

`, }); } } async resetPassword(token: string, newPassword: string) { try { const payload = this.jwtService.verify<{ sub: string; purpose: string }>(token); if (payload.purpose !== "password_reset") { throw new BadRequestException("Invalid token"); } const user = await this.usersService.findById(payload.sub); if (!user) throw new BadRequestException("Invalid token"); const saltRoundsConfig = this.configService.get("BCRYPT_ROUNDS", 12); const saltRounds = typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig; const passwordHash = await bcrypt.hash(newPassword, saltRounds); const updatedUser = await this.usersService.update(user.id, { passwordHash }); const tokens = this.generateTokens(updatedUser); return { user: this.sanitizeUser(updatedUser), ...tokens, }; } catch (error) { this.logger.error("Reset password failed", { error: getErrorMessage(error) }); throw new BadRequestException("Invalid or expired token"); } } private validateSignupData(signupData: SignupDto) { const { email, password, firstName, lastName } = signupData; if (!email || !password || !firstName || !lastName) { throw new BadRequestException( "Email, password, firstName, and lastName are required for signup." ); } if (!email.includes("@")) { throw new BadRequestException("Invalid email address."); } if (password.length < 8) { throw new BadRequestException("Password must be at least 8 characters long."); } // Password must contain at least one uppercase letter, one lowercase letter, and one number if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(password)) { throw new BadRequestException( "Password must contain at least one uppercase letter, one lowercase letter, and one number." ); } } }