464 lines
14 KiB
TypeScript
464 lines
14 KiB
TypeScript
|
|
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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|