Assist_Design/apps/bff/src/auth/auth.service.ts

464 lines
14 KiB
TypeScript
Raw Normal View History

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<any> {
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<void> {
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<void> {
// 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;
}
}
}