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

1144 lines
37 KiB
TypeScript
Raw Normal View History

2025-08-21 15:24:40 +09:00
import {
Injectable,
UnauthorizedException,
ConflictException,
BadRequestException,
NotFoundException,
2025-08-22 17:02:49 +09:00
Inject,
2025-08-21 15:24:40 +09:00
} 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 { ValidateSignupDto } from "./dto/validate-signup.dto";
2025-08-21 15:24:40 +09:00
import { SetPasswordDto } from "./dto/set-password.dto";
import { getErrorMessage } from "../common/utils/error.util";
2025-08-22 17:02:49 +09:00
import { Logger } from "nestjs-pino";
import { EmailService } from "../common/email/email.service";
2025-08-23 18:02:05 +09:00
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";
import { PrismaService } from "../common/prisma/prisma.service";
@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,
private prisma: PrismaService,
2025-08-22 17:02:49 +09:00
@Inject(Logger) private readonly logger: Logger
) {}
async healthCheck() {
const health = {
database: false,
whmcs: false,
salesforce: false,
whmcsConfig: {
baseUrl: !!this.configService.get("WHMCS_BASE_URL"),
identifier: !!this.configService.get("WHMCS_API_IDENTIFIER"),
secret: !!this.configService.get("WHMCS_API_SECRET"),
},
salesforceConfig: {
connected: false,
},
};
// Check database
try {
await this.usersService.findByEmail("health-check@test.com");
health.database = true;
} catch (error) {
this.logger.debug("Database health check failed", { error: getErrorMessage(error) });
}
// Check WHMCS
try {
// Try a simple WHMCS API call (this will fail if not configured)
await this.whmcsService.getProducts();
health.whmcs = true;
} catch (error) {
this.logger.debug("WHMCS health check failed", { error: getErrorMessage(error) });
}
// Check Salesforce
try {
health.salesforceConfig.connected = this.salesforceService.healthCheck();
health.salesforce = health.salesforceConfig.connected;
} catch (error) {
this.logger.debug("Salesforce health check failed", { error: getErrorMessage(error) });
}
return {
status: health.database && health.whmcs && health.salesforce ? "healthy" : "degraded",
services: health,
timestamp: new Date().toISOString(),
};
}
async validateSignup(validateData: ValidateSignupDto, request?: Request) {
const { sfNumber } = validateData;
try {
// 1. Check if SF number exists in Salesforce
const sfAccount = await this.salesforceService.findAccountByCustomerNumber(sfNumber);
if (!sfAccount) {
await this.auditService.logAuthEvent(
AuditAction.SIGNUP,
undefined,
{ sfNumber, reason: "SF number not found" },
request,
false,
"Customer number not found in Salesforce"
);
throw new BadRequestException("Customer number not found in Salesforce");
}
// 2. Check if SF account already has a mapping (already registered)
const existingMapping = await this.mappingsService.findBySfAccountId(sfAccount.id);
if (existingMapping) {
await this.auditService.logAuthEvent(
AuditAction.SIGNUP,
undefined,
{ sfNumber, sfAccountId: sfAccount.id, reason: "Already has mapping" },
request,
false,
"Customer number already registered"
);
throw new ConflictException(
"You already have an account. Please use the login page to access your existing account."
);
}
// 3. Check WH_Account__c field in Salesforce
const accountDetails = await this.salesforceService.getAccountDetails(sfAccount.id);
if (accountDetails?.WH_Account__c && accountDetails.WH_Account__c.trim() !== "") {
await this.auditService.logAuthEvent(
AuditAction.SIGNUP,
undefined,
{
sfNumber,
sfAccountId: sfAccount.id,
whAccount: accountDetails.WH_Account__c,
reason: "WH Account not empty",
},
request,
false,
"Account already has WHMCS integration"
);
throw new ConflictException(
"You already have an account. Please use the login page to access your existing account."
);
}
// Log successful validation
await this.auditService.logAuthEvent(
AuditAction.SIGNUP,
undefined,
{ sfNumber, sfAccountId: sfAccount.id, step: "validation" },
request,
true
);
return {
valid: true,
sfAccountId: sfAccount.id,
message: "Customer number validated successfully",
};
} catch (error) {
// Re-throw known exceptions
if (error instanceof BadRequestException || error instanceof ConflictException) {
throw error;
}
// Log unexpected errors
await this.auditService.logAuthEvent(
AuditAction.SIGNUP,
undefined,
{ sfNumber, error: getErrorMessage(error) },
request,
false,
getErrorMessage(error)
);
this.logger.error("Signup validation error", { error: getErrorMessage(error) });
throw new BadRequestException("Validation failed");
}
}
2025-08-23 18:02:05 +09:00
async signup(signupData: SignupDto, request?: Request) {
const {
email,
password,
firstName,
lastName,
company,
phone,
sfNumber,
address,
nationality,
dateOfBirth,
gender,
} = signupData;
2025-08-21 15:24:40 +09:00
2025-08-22 17:02:49 +09:00
// Enhanced input validation
this.validateSignupData(signupData);
// Check if a portal user already exists (do not create anything yet)
2025-08-23 18:02:05 +09:00
const existingUser: PrismaUser | null = await this.usersService.findByEmailInternal(email);
if (existingUser) {
const mapped = await this.mappingsService.hasMapping(existingUser.id);
const message = mapped
? "You already have an account. Please sign in."
: "You already have an account with us. Please sign in to continue setup.";
await this.auditService.logAuthEvent(
AuditAction.SIGNUP,
existingUser.id,
{ email, reason: mapped ? "mapped_user_exists" : "unmapped_user_exists" },
request,
false,
message
);
throw new ConflictException(message);
}
// Hash password with environment-based configuration (computed ahead, used after WHMCS success)
2025-08-27 10:54:05 +09:00
const saltRoundsConfig = this.configService.get<string | number>("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)
2025-08-23 18:02:05 +09:00
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 client in WHMCS first (no portal user is created yet)
let whmcsClient: { clientId: number };
try {
// 1.0 Pre-check for existing WHMCS client by email to avoid duplicates and guide UX
try {
const existingWhmcs = await this.whmcsService.getClientDetailsByEmail(email);
if (existingWhmcs) {
// If a mapping already exists for this WHMCS client, user should sign in
const existingMapping = await this.mappingsService.findByWhmcsClientId(
existingWhmcs.id
);
if (existingMapping) {
throw new ConflictException("You already have an account. Please sign in.");
}
// Otherwise, instruct to link the existing billing account instead of creating a new one
throw new ConflictException(
"We found an existing billing account for this email. Please link your account instead."
);
}
} catch (pre) {
// Continue only if the client was not found; rethrow other errors
if (!(pre instanceof NotFoundException)) {
throw pre;
}
}
// Prepare WHMCS custom fields (IDs configurable via env)
const customerNumberFieldId = this.configService.get<string>(
"WHMCS_CUSTOMER_NUMBER_FIELD_ID",
"198"
);
const dobFieldId = this.configService.get<string>("WHMCS_DOB_FIELD_ID");
const genderFieldId = this.configService.get<string>("WHMCS_GENDER_FIELD_ID");
const nationalityFieldId = this.configService.get<string>("WHMCS_NATIONALITY_FIELD_ID");
const customfields: Record<string, string> = {};
if (customerNumberFieldId) customfields[customerNumberFieldId] = sfNumber;
if (dobFieldId && dateOfBirth) customfields[dobFieldId] = dateOfBirth;
if (genderFieldId && gender) customfields[genderFieldId] = gender;
if (nationalityFieldId && nationality) customfields[nationalityFieldId] = nationality;
this.logger.log("Creating WHMCS client", { email, firstName, lastName, sfNumber });
// Validate required WHMCS fields
if (
!address?.line1 ||
!address?.city ||
!address?.state ||
!address?.postalCode ||
!address?.country
) {
throw new BadRequestException(
"Complete address information is required for billing account creation"
);
}
if (!phone) {
throw new BadRequestException("Phone number is required for billing account creation");
}
this.logger.log("WHMCS client data", {
email,
firstName,
lastName,
address: address,
phone,
country: address.country,
});
whmcsClient = await this.whmcsService.addClient({
firstname: firstName,
lastname: lastName,
email,
companyname: company || "",
phonenumber: phone,
address1: address.line1,
address2: address.line2 || "",
city: address.city,
state: address.state,
postcode: address.postalCode,
country: address.country,
password2: password, // WHMCS requires plain password for new clients
customfields,
});
this.logger.log("WHMCS client created successfully", {
clientId: whmcsClient.clientId,
email,
});
} catch (whmcsError) {
this.logger.error("Failed to create WHMCS client", {
error: getErrorMessage(whmcsError),
email,
firstName,
lastName,
});
throw new BadRequestException(
`Failed to create billing account: ${getErrorMessage(whmcsError)}`
);
}
// 2. Only now create the portal user and mapping atomically
const { createdUserId } = await this.prisma.$transaction(async tx => {
const created = await tx.user.create({
data: {
email,
passwordHash,
firstName,
lastName,
company: company || null,
phone: phone || null,
emailVerified: false,
failedLoginAttempts: 0,
lockedUntil: null,
lastLoginAt: null,
},
select: { id: true, email: true },
});
await tx.idMapping.create({
data: {
userId: created.id,
whmcsClientId: whmcsClient.clientId,
sfAccountId: sfAccount.id,
},
});
return { createdUserId: created.id };
});
// Fetch sanitized user response
const freshUser = await this.usersService.findByIdInternal(createdUserId);
// Log successful signup
await this.auditService.logAuthEvent(
AuditAction.SIGNUP,
createdUserId,
{ email, whmcsClientId: whmcsClient.clientId },
request,
2025-08-22 17:02:49 +09:00
true
);
const tokens = this.generateTokens({ id: createdUserId, email });
return {
user: this.sanitizeUser(
freshUser ?? ({ id: createdUserId, email } as unknown as PrismaUser)
),
...tokens,
};
} catch (error) {
// Log failed signup
await this.auditService.logAuthEvent(
AuditAction.SIGNUP,
undefined,
{ email, error: getErrorMessage(error) },
request,
false,
2025-08-22 17:02:49 +09:00
getErrorMessage(error)
);
2025-08-21 15:24:40 +09:00
// TODO: Implement rollback logic if any step fails
2025-08-22 17:02:49 +09:00
this.logger.error("Signup error", { error: getErrorMessage(error) });
2025-08-21 15:24:40 +09:00
throw new BadRequestException("Failed to create user account");
}
}
2025-08-22 17:02:49 +09:00
async login(
user: {
id: string;
email: string;
role?: string;
passwordHash?: string | null;
failedLoginAttempts?: number | null;
lockedUntil?: Date | null;
},
2025-08-27 10:54:05 +09:00
request?: Request
2025-08-22 17:02:49 +09:00
) {
// 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-22 17:02:49 +09:00
true
);
2025-08-27 10:54:05 +09:00
const tokens = this.generateTokens(user);
return {
user: this.sanitizeUser(user),
...tokens,
};
}
2025-08-23 18:02:05 +09:00
async linkWhmcsUser(linkData: LinkWhmcsDto, _request?: Request) {
const { email, password } = linkData;
// Check if user already exists in portal
2025-08-23 18:02:05 +09:00
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) {
2025-08-21 15:24:40 +09:00
this.logger.log(
2025-08-22 17:02:49 +09:00
`User ${email} exists but has no password - allowing password setup to continue`
2025-08-21 15:24:40 +09:00
);
return {
user: this.sanitizeUser(existingUser),
needsPasswordSet: true,
};
} else {
2025-08-21 15:24:40 +09:00
throw new ConflictException(
2025-08-22 17:02:49 +09:00
"User already exists in portal and has completed setup. Please use the login page."
2025-08-21 15:24:40 +09:00
);
}
}
try {
// 1. First, find the client by email using GetClientsDetails directly
2025-08-23 18:02:05 +09:00
let clientDetails: WhmcsClientResponse["client"];
try {
clientDetails = await this.whmcsService.getClientDetailsByEmail(email);
} catch (error) {
2025-08-22 17:02:49 +09:00
this.logger.warn(`WHMCS client lookup failed for email ${email}`, {
error: getErrorMessage(error),
});
throw new UnauthorizedException("WHMCS client not found with this email address");
}
// 1.a If this WHMCS client is already mapped, direct the user to sign in instead
try {
const existingMapping = await this.mappingsService.findByWhmcsClientId(clientDetails.id);
if (existingMapping) {
throw new ConflictException("This billing account is already linked. Please sign in.");
}
} catch (mapErr) {
if (mapErr instanceof ConflictException) throw mapErr;
// ignore not-found mapping cases; proceed with linking
}
// 2. Validate the password using ValidateLogin
try {
this.logger.debug(`About to validate WHMCS password for ${email}`);
2025-08-22 17:02:49 +09:00
const validateResult = await this.whmcsService.validateLogin(email, password);
this.logger.debug("WHMCS validation successful", { email });
if (!validateResult || !validateResult.userId) {
2025-08-21 15:24:40 +09:00
throw new UnauthorizedException("Invalid WHMCS credentials");
}
} catch (error) {
2025-08-22 17:02:49 +09:00
this.logger.debug("WHMCS validation failed", { email, error: getErrorMessage(error) });
2025-08-21 15:24:40 +09:00
throw new UnauthorizedException("Invalid WHMCS password");
}
// 3. Extract Customer Number from field ID 198
2025-08-28 18:57:12 +09:00
const customerNumberField = clientDetails.customfields?.find(field => field.id === 198);
const customerNumber = customerNumberField?.value?.trim();
2025-08-28 18:57:12 +09:00
if (!customerNumber) {
throw new BadRequestException(
2025-08-28 18:57:12 +09:00
`Customer Number not found in WHMCS custom field 198. Please contact support.`
);
}
2025-08-21 15:24:40 +09:00
this.logger.log(
2025-08-22 17:02:49 +09:00
`Found Customer Number: ${customerNumber} for WHMCS client ${clientDetails.id}`
2025-08-21 15:24:40 +09:00
);
// 3. Find existing Salesforce account using Customer Number
2025-08-23 18:02:05 +09:00
const sfAccount: { id: string } | null =
await this.salesforceService.findAccountByCustomerNumber(customerNumber);
if (!sfAccount) {
2025-08-21 15:24:40 +09:00
throw new BadRequestException(
2025-08-22 17:02:49 +09:00
`Salesforce account not found for Customer Number: ${customerNumber}. Please contact support.`
2025-08-21 15:24:40 +09:00
);
}
// 4. Create portal user (without password initially)
2025-08-23 18:02:05 +09:00
const user: SharedUser = 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,
2025-08-23 18:02:05 +09:00
whmcsClientId: clientDetails.id,
sfAccountId: sfAccount.id,
});
return {
user: this.sanitizeUser(user),
needsPasswordSet: true,
};
} catch (error) {
2025-08-22 17:02:49 +09:00
this.logger.error("WHMCS linking error", { error: getErrorMessage(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) {
2025-08-23 18:02:05 +09:00
const user: PrismaUser | null = 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,
};
}
2025-08-23 18:02:05 +09:00
async setPassword(setPasswordData: SetPasswordDto, _request?: Request) {
const { email, password } = setPasswordData;
2025-08-23 18:02:05 +09:00
const user: PrismaUser | null = 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-23 18:02:05 +09:00
const updatedUser: SharedUser = await this.usersService.update(user.id, {
2025-08-21 15:24:40 +09:00
passwordHash,
});
// Generate tokens
2025-08-27 10:54:05 +09:00
const tokens = this.generateTokens(updatedUser);
return {
user: this.sanitizeUser(updatedUser),
...tokens,
};
}
2025-08-21 15:24:40 +09:00
async validateUser(
email: string,
password: string,
2025-08-23 18:02:05 +09:00
_request?: Request
): Promise<PrismaUser | null> {
const user: PrismaUser | null = 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" },
2025-08-22 17:02:49 +09:00
_request,
false,
2025-08-22 17:02:49 +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" },
2025-08-22 17:02:49 +09:00
_request,
false,
2025-08-22 17:02:49 +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" },
2025-08-22 17:02:49 +09:00
_request,
false,
2025-08-22 17:02:49 +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
2025-08-22 17:02:49 +09:00
await this.handleFailedLogin(user, _request);
return null;
}
} catch (error) {
2025-08-22 17:02:49 +09:00
this.logger.error("Password validation error", { email, error: getErrorMessage(error) });
await this.auditService.logAuthEvent(
AuditAction.LOGIN_FAILED,
user.id,
{ email, error: getErrorMessage(error) },
2025-08-22 17:02:49 +09:00
_request,
false,
2025-08-22 17:02:49 +09:00
getErrorMessage(error)
);
return null;
}
}
2025-08-27 10:54:05 +09:00
private async handleFailedLogin(user: PrismaUser, _request?: Request): 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-22 17:02:49 +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(),
},
2025-08-22 17:02:49 +09:00
_request,
false,
2025-08-22 17:02:49 +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(),
},
2025-08-22 17:02:49 +09:00
_request,
false,
2025-08-22 17:02:49 +09:00
`Account locked for ${this.LOCKOUT_DURATION_MINUTES} minutes`
);
}
}
2025-08-23 18:02:05 +09:00
async logout(userId: string, token: string, _request?: Request): Promise<void> {
// Blacklist the token
await this.tokenBlacklistService.blacklistToken(token);
2025-08-21 15:24:40 +09:00
2025-08-22 17:02:49 +09:00
await this.auditService.logAuthEvent(AuditAction.LOGOUT, userId, {}, _request, true);
}
// Helper methods
2025-08-23 18:02:05 +09:00
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),
};
}
2025-08-22 17:02:49 +09:00
private sanitizeUser<
T extends {
id: string;
email: string;
role?: string;
passwordHash?: string | null;
failedLoginAttempts?: number | null;
lockedUntil?: Date | null;
},
>(user: T): Omit<T, "passwordHash" | "failedLoginAttempts" | "lockedUntil"> {
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
*/
2025-08-21 15:24:40 +09:00
async createSsoLink(
userId: string,
2025-08-22 17:02:49 +09:00
destination?: string
2025-08-21 15:24:40 +09:00
): 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-22 17:02:49 +09:00
const ssoDestination = "sso:custom_redirect";
const ssoRedirectPath = this.sanitizeWhmcsRedirectPath(destination);
2025-08-21 15:24:40 +09:00
const result = await this.whmcsService.createSsoToken(
mapping.whmcsClientId,
ssoDestination,
2025-08-22 17:02:49 +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;
}
}
2025-08-22 17:02:49 +09:00
/**
* Ensure only safe, relative WHMCS paths are allowed for SSO redirects.
* Falls back to 'clientarea.php' when input is missing or unsafe.
*/
private sanitizeWhmcsRedirectPath(path?: string): string {
// Default
const fallback = "clientarea.php";
if (!path || typeof path !== "string") return fallback;
const p = path.trim();
// Disallow absolute URLs and protocol-like prefixes
if (/^https?:\/\//i.test(p) || /^\/\//.test(p) || /^sso:/i.test(p)) {
return fallback;
}
// Allow only known entry points; expand as needed
const allowedStarts = [
"clientarea.php",
"index.php?rp=/account/paymentmethods",
"viewinvoice.php",
"dl.php",
];
const isAllowed = allowedStarts.some(prefix => p.toLowerCase().startsWith(prefix.toLowerCase()));
return isAllowed ? p : fallback;
}
async requestPasswordReset(email: string): Promise<void> {
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" }
);
2025-08-27 10:54:05 +09:00
const appBase = this.configService.get<string>("APP_BASE_URL", "http://localhost:3000");
const resetUrl = `${appBase}/auth/reset-password?token=${encodeURIComponent(token)}`;
const templateId = this.configService.get<string>("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: `
<p>We received a request to reset your password.</p>
<p><a href="${resetUrl}">Click here to reset your password</a>. This link expires in 15 minutes.</p>
<p>If you didn't request this, you can safely ignore this email.</p>
`,
});
}
}
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");
2025-08-27 10:54:05 +09:00
const saltRoundsConfig = this.configService.get<string | number>("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 });
2025-08-27 10:54:05 +09:00
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");
}
}
async getAccountStatus(email: string) {
// Normalize email
const normalized = email?.toLowerCase().trim();
if (!normalized || !normalized.includes("@")) {
throw new BadRequestException("Valid email is required");
}
let portalUser: PrismaUser | null = null;
let mapped = false;
let whmcsExists = false;
let needsPasswordSet = false;
try {
portalUser = await this.usersService.findByEmailInternal(normalized);
if (portalUser) {
mapped = await this.mappingsService.hasMapping(portalUser.id);
needsPasswordSet = !portalUser.passwordHash;
}
} catch (e) {
this.logger.warn("Account status: portal lookup failed", { error: getErrorMessage(e) });
}
// If already mapped, we can assume a WHMCS client exists
if (mapped) {
whmcsExists = true;
} else {
// Try a direct WHMCS lookup by email (best-effort)
try {
const client = await this.whmcsService.getClientDetailsByEmail(normalized);
whmcsExists = !!client;
} catch (e) {
// Treat not found as no; other errors as unknown (leave whmcsExists false)
this.logger.debug("Account status: WHMCS lookup", { error: getErrorMessage(e) });
}
}
let state: "none" | "portal_only" | "whmcs_only" | "both_mapped" = "none";
if (portalUser && mapped) state = "both_mapped";
else if (portalUser) state = "portal_only";
else if (whmcsExists) state = "whmcs_only";
const recommendedAction = (() => {
switch (state) {
case "both_mapped":
return "sign_in" as const;
case "portal_only":
return needsPasswordSet ? ("set_password" as const) : ("sign_in" as const);
case "whmcs_only":
return "link_account" as const;
case "none":
default:
return "sign_up" as const;
}
})();
return {
state,
portalUserExists: !!portalUser,
whmcsClientExists: whmcsExists,
mapped,
needsPasswordSet,
recommendedAction,
};
}
async changePassword(userId: string, currentPassword: string, newPassword: string) {
// Fetch raw user with passwordHash
const user = await this.usersService.findByIdInternal(userId);
if (!user) {
throw new UnauthorizedException("User not found");
}
if (!user.passwordHash) {
throw new BadRequestException("No password set. Please set a password first.");
}
const isCurrentValid = await bcrypt.compare(currentPassword, user.passwordHash);
if (!isCurrentValid) {
await this.auditService.logAuthEvent(
AuditAction.PASSWORD_CHANGE,
user.id,
{ action: "change_password", reason: "invalid_current_password" },
undefined,
false,
"Invalid current password"
);
throw new BadRequestException("Current password is incorrect");
}
// Validate new password strength (reusing signup policy)
if (
newPassword.length < 8 ||
!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/.test(newPassword)
) {
throw new BadRequestException(
"Password must be at least 8 characters and include uppercase, lowercase, number, and special character."
);
}
const saltRoundsConfig = this.configService.get<string | number>("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 });
await this.auditService.logAuthEvent(
AuditAction.PASSWORD_CHANGE,
user.id,
{ action: "change_password" },
undefined,
true
);
// Issue fresh tokens
const tokens = this.generateTokens(updatedUser);
return {
user: this.sanitizeUser(updatedUser),
...tokens,
};
}
/**
* Preflight validation for signup. No side effects.
* Returns a clear nextAction for the UI and detailed flags.
*/
async signupPreflight(signupData: SignupDto) {
const { email, sfNumber } = signupData;
const normalizedEmail = email.toLowerCase().trim();
const result: {
ok: boolean;
canProceed: boolean;
nextAction:
| "proceed_signup"
| "link_whmcs"
| "login"
| "fix_input"
| "blocked";
messages: string[];
normalized: { email: string };
portal: { userExists: boolean; needsPasswordSet?: boolean };
salesforce: { accountId?: string; alreadyMapped: boolean };
whmcs: { clientExists: boolean; clientId?: number };
} = {
ok: true,
canProceed: false,
nextAction: "blocked",
messages: [],
normalized: { email: normalizedEmail },
portal: { userExists: false },
salesforce: { alreadyMapped: false },
whmcs: { clientExists: false },
};
// 0) Portal user existence
const portalUser = await this.usersService.findByEmailInternal(normalizedEmail);
if (portalUser) {
result.portal.userExists = true;
const mapped = await this.mappingsService.hasMapping(portalUser.id);
if (mapped) {
result.nextAction = "login";
result.messages.push("An account already exists. Please sign in.");
return result;
}
// Legacy unmapped user
result.portal.needsPasswordSet = !portalUser.passwordHash;
result.nextAction = portalUser.passwordHash ? "login" : "fix_input";
result.messages.push(
portalUser.passwordHash
? "An account exists without billing link. Please sign in to continue setup."
: "An account exists and needs password setup. Please set a password to continue."
);
return result;
}
// 1) Salesforce checks
const sfAccount = await this.salesforceService.findAccountByCustomerNumber(sfNumber);
if (!sfAccount) {
result.nextAction = "fix_input";
result.messages.push("Customer number not found in Salesforce");
return result;
}
result.salesforce.accountId = sfAccount.id;
const existingMapping = await this.mappingsService.findBySfAccountId(sfAccount.id);
if (existingMapping) {
result.salesforce.alreadyMapped = true;
result.nextAction = "login";
result.messages.push("This customer number is already registered. Please sign in.");
return result;
}
// 2) WHMCS checks by email
try {
const client = await this.whmcsService.getClientDetailsByEmail(normalizedEmail);
if (client) {
result.whmcs.clientExists = true;
result.whmcs.clientId = client.id;
// If this WHMCS client is already linked to a portal user, direct to login
try {
const mapped = await this.mappingsService.findByWhmcsClientId(client.id);
if (mapped) {
result.nextAction = "login";
result.messages.push("This billing account is already linked. Please sign in.");
return result;
}
} catch {
// ignore; treat as unmapped
}
// Client exists but not mapped → suggest linking instead of creating new
result.nextAction = "link_whmcs";
result.messages.push(
"We found an existing billing account for this email. Please link your account."
);
return result;
}
} catch (err) {
// NotFoundException is expected when client doesn't exist. Other errors are reported.
if (!(err instanceof NotFoundException)) {
this.logger.warn("WHMCS preflight check failed", { error: getErrorMessage(err) });
result.messages.push("Unable to verify billing system. Please try again later.");
result.nextAction = "blocked";
return result;
}
}
// If we reach here: no portal user, SF valid and not mapped, no WHMCS client → OK to proceed
result.canProceed = true;
result.nextAction = "proceed_signup";
result.messages.push("All checks passed. Ready to create your account.");
return result;
}
2025-08-22 17:02:49 +09:00
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, one number, and one special character
if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/.test(password)) {
2025-08-22 17:02:49 +09:00
throw new BadRequestException(
"Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character."
2025-08-22 17:02:49 +09:00
);
}
}
}