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

509 lines
15 KiB
TypeScript
Raw Normal View History

2025-08-21 15:24:40 +09:00
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;
2025-08-21 15:24:40 +09:00
// Check if user already exists
const existingUser = await this.usersService.findByEmailInternal(email);
if (existingUser) {
await this.auditService.logAuthEvent(
AuditAction.SIGNUP,
undefined,
2025-08-21 15:24:40 +09:00
{ email, reason: "User already exists" },
request,
false,
2025-08-21 15:24:40 +09:00
"User with this email already exists",
);
2025-08-21 15:24:40 +09:00
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,
2025-08-21 15:24:40 +09:00
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,
2025-08-21 15:24:40 +09:00
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,
2025-08-21 15:24:40 +09:00
getErrorMessage(error),
);
2025-08-21 15:24:40 +09:00
// TODO: Implement rollback logic if any step fails
2025-08-21 15:24:40 +09:00
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,
2025-08-21 15:24:40 +09:00
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) {
2025-08-21 15:24:40 +09:00
this.logger.log(
`User ${email} exists but has no password - allowing password setup to continue`,
);
return {
user: this.sanitizeUser(existingUser),
needsPasswordSet: true,
};
} else {
2025-08-21 15:24:40 +09:00
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) {
2025-08-21 15:24:40 +09:00
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}`);
2025-08-21 15:24:40 +09:00
const validateResult = await this.whmcsService.validateLogin(
email,
password,
);
this.logger.debug(
`WHMCS validation successful for ${email}:`,
validateResult,
);
if (!validateResult || !validateResult.userId) {
2025-08-21 15:24:40 +09:00
throw new UnauthorizedException("Invalid WHMCS credentials");
}
} catch (error) {
2025-08-21 15:24:40 +09:00
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(
2025-08-21 15:24:40 +09:00
(field: any) => field.id == 198, // Use == instead of === to handle string/number comparison
);
2025-08-21 15:24:40 +09:00
const customerNumber = customerNumberField?.value;
2025-08-21 15:24:40 +09:00
if (!customerNumber || customerNumber.toString().trim() === "") {
throw new BadRequestException(
`Customer Number not found in WHMCS custom field 198. ` +
2025-08-21 15:24:40 +09:00
`Found field: ${JSON.stringify(customerNumberField)}. ` +
`Available custom fields: ${JSON.stringify(clientDetails.customfields || [])}. ` +
`Please contact support.`,
);
}
2025-08-21 15:24:40 +09:00
this.logger.log(
`Found Customer Number: ${customerNumber} for WHMCS client ${clientDetails.id}`,
);
// 3. Find existing Salesforce account using Customer Number
2025-08-21 15:24:40 +09:00
const sfAccount =
await this.salesforceService.findAccountByCustomerNumber(
customerNumber,
);
if (!sfAccount) {
2025-08-21 15:24:40 +09:00
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
2025-08-21 15:24:40 +09:00
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) {
2025-08-21 15:24:40 +09:00
this.logger.error("WHMCS linking error:", error);
if (
error instanceof BadRequestException ||
error instanceof UnauthorizedException
) {
throw error;
}
2025-08-21 15:24:40 +09:00
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 };
}
2025-08-21 15:24:40 +09:00
return {
needsPasswordSet: !user.passwordHash,
userExists: true,
2025-08-21 15:24:40 +09:00
email: user.email,
};
}
async setPassword(setPasswordData: SetPasswordDto, request?: any) {
const { email, password } = setPasswordData;
const user = await this.usersService.findByEmailInternal(email);
if (!user) {
2025-08-21 15:24:40 +09:00
throw new UnauthorizedException("User not found");
}
// Check if user needs to set password (linked users have null password hash)
if (user.passwordHash) {
2025-08-21 15:24:40 +09:00
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
2025-08-21 15:24:40 +09:00
const updatedUser = await this.usersService.update(user.id, {
passwordHash,
});
// Generate tokens
const tokens = await this.generateTokens(updatedUser);
return {
user: this.sanitizeUser(updatedUser),
...tokens,
};
}
2025-08-21 15:24:40 +09:00
async validateUser(
email: string,
password: string,
request?: any,
): Promise<any> {
const user = await this.usersService.findByEmailInternal(email);
2025-08-21 15:24:40 +09:00
if (!user) {
await this.auditService.logAuthEvent(
AuditAction.LOGIN_FAILED,
undefined,
2025-08-21 15:24:40 +09:00
{ email, reason: "User not found" },
request,
false,
2025-08-21 15:24:40 +09:00
"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,
2025-08-21 15:24:40 +09:00
{ email, reason: "Account locked" },
request,
false,
2025-08-21 15:24:40 +09:00
"Account is locked",
);
return null;
}
2025-08-21 15:24:40 +09:00
if (!user.passwordHash) {
await this.auditService.logAuthEvent(
AuditAction.LOGIN_FAILED,
user.id,
2025-08-21 15:24:40 +09:00
{ email, reason: "No password set" },
request,
false,
2025-08-21 15:24:40 +09:00
"No password set",
);
return null;
}
try {
const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
2025-08-21 15:24:40 +09:00
if (isPasswordValid) {
return user;
} else {
// Increment failed login attempts
await this.handleFailedLogin(user, request);
return null;
}
} catch (error) {
2025-08-21 15:24:40 +09:00
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,
2025-08-21 15:24:40 +09:00
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();
2025-08-21 15:24:40 +09:00
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,
2025-08-21 15:24:40 +09:00
{
email: user.email,
failedAttempts: newFailedAttempts,
2025-08-21 15:24:40 +09:00
lockedUntil: lockedUntil?.toISOString(),
},
request,
false,
2025-08-21 15:24:40 +09:00
"Invalid password",
);
// Log account lock if applicable
if (isAccountLocked) {
await this.auditService.logAuthEvent(
AuditAction.ACCOUNT_LOCKED,
user.id,
2025-08-21 15:24:40 +09:00
{
email: user.email,
lockDuration: this.LOCKOUT_DURATION_MINUTES,
2025-08-21 15:24:40 +09:00
lockedUntil: lockedUntil?.toISOString(),
},
request,
false,
2025-08-21 15:24:40 +09:00
`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);
2025-08-21 15:24:40 +09:00
await this.auditService.logAuthEvent(
AuditAction.LOGOUT,
userId,
{},
request,
2025-08-21 15:24:40 +09:00
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) {
2025-08-21 15:24:40 +09:00
const { passwordHash, failedLoginAttempts, lockedUntil, ...sanitizedUser } =
user;
return sanitizedUser;
}
/**
* Create SSO link to WHMCS for general access
*/
2025-08-21 15:24:40 +09:00
async createSsoLink(
userId: string,
destination?: string,
): Promise<{ url: string; expiresAt: string }> {
try {
// Production-safe logging - no sensitive data
2025-08-21 15:24:40 +09:00
this.logger.log("Creating SSO link request");
// Get WHMCS client ID from user mapping
const mapping = await this.mappingsService.findByUserId(userId);
2025-08-21 15:24:40 +09:00
if (!mapping?.whmcsClientId) {
2025-08-21 15:24:40 +09:00
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
2025-08-21 15:24:40 +09:00
let ssoDestination = "sso:custom_redirect";
let ssoRedirectPath = destination || "clientarea.php";
const result = await this.whmcsService.createSsoToken(
mapping.whmcsClientId,
ssoDestination,
2025-08-21 15:24:40 +09:00
ssoRedirectPath,
);
2025-08-21 15:24:40 +09:00
this.logger.log("SSO link created successfully");
return result;
} catch (error) {
// Production-safe error logging - no sensitive data or stack traces
2025-08-21 15:24:40 +09:00
this.logger.error("SSO link creation failed", {
errorType: error instanceof Error ? error.constructor.name : "Unknown",
message: getErrorMessage(error),
});
throw error;
}
}
}