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

850 lines
27 KiB
TypeScript
Raw Normal View History

2025-08-21 15:24:40 +09:00
import {
Injectable,
UnauthorizedException,
ConflictException,
BadRequestException,
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";
@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,
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 user already exists
2025-08-23 18:02:05 +09:00
const existingUser: PrismaUser | null = 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-22 17:02:49 +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");
}
2025-08-22 17:02:49 +09:00
// Hash password with environment-based configuration
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 user in portal
2025-08-23 18:02:05 +09:00
const user: SharedUser = await this.usersService.create({
email,
passwordHash,
firstName,
lastName,
company,
phone,
emailVerified: false,
failedLoginAttempts: 0,
lockedUntil: null,
lastLoginAt: null,
});
// 2. Create client in WHMCS
let whmcsClient: { clientId: number };
try {
// 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,
});
// Rollback: Delete the portal user since WHMCS creation failed
try {
// Note: We should add a delete method to UsersService, but for now use direct approach
this.logger.warn("WHMCS creation failed, user account created but not fully integrated", {
userId: user.id,
email,
whmcsError: getErrorMessage(whmcsError),
});
} catch (rollbackError) {
this.logger.error("Failed to log rollback information", {
userId: user.id,
email,
rollbackError: getErrorMessage(rollbackError),
});
}
throw new BadRequestException(
`Failed to create billing account: ${getErrorMessage(whmcsError)}`
);
}
// 3. Store ID mappings
await this.mappingsService.createMapping({
userId: user.id,
whmcsClientId: whmcsClient.clientId,
sfAccountId: sfAccount.id,
});
// 4. Update WH_Account__c field in Salesforce
const whAccountValue = `#${whmcsClient.clientId} - ${firstName} ${lastName}`;
await this.salesforceService.updateWhAccount(sfAccount.id, whAccountValue);
// Log successful signup
await this.auditService.logAuthEvent(
AuditAction.SIGNUP,
user.id,
{ email, whmcsClientId: whmcsClient.clientId, whAccountValue },
request,
2025-08-22 17:02:49 +09:00
true
);
// Generate JWT token
2025-08-27 10:54:05 +09:00
const tokens = 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-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");
}
// 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 = destination || "clientarea.php";
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
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");
}
}
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, and one number
if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(password)) {
throw new BadRequestException(
"Password must contain at least one uppercase letter, one lowercase letter, and one number."
);
}
}
}