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; } } }