Enhance WHMCS integration and validation processes

- Updated .env.example and portal-backend.env.sample to include new development-specific WHMCS environment variables.
- Improved error handling in AuthService with a new signupPreflight method for validating signup data without creating accounts.
- Enhanced phone number validation in SignupDto to enforce stricter formatting rules.
- Refactored WHMCS connection service to prioritize development environment variables and normalize redirect URLs for SSO tokens.
- Added debug logging for SSO redirect hosts in non-production environments to aid in troubleshooting.
This commit is contained in:
T. Narantuya 2025-09-06 17:38:42 +09:00
parent 79f11edb65
commit cb3cb8fec8
10 changed files with 374 additions and 73 deletions

View File

@ -44,12 +44,21 @@ CORS_ORIGIN="http://localhost:3000"
# 🏢 EXTERNAL API CONFIGURATION (Development)
# =============================================================================
# WHMCS Integration (use your actual credentials)
# Note: Do not include a trailing slash in BASE_URL.
WHMCS_BASE_URL="https://accounts.asolutions.co.jp"
WHMCS_API_IDENTIFIER="your_whmcs_api_identifier"
WHMCS_API_SECRET="your_whmcs_api_secret"
# Optional: Some deployments require an API Access Key in addition to credentials
# WHMCS_API_ACCESS_KEY="your_whmcs_api_access_key"
# WHMCS Dev Overrides (used when NODE_ENV !== "production")
# These override the above values in development/test.
# Note: Do not include a trailing slash in DEV_BASE_URL.
# WHMCS_DEV_BASE_URL="https://dev-wh.asolutions.co.jp"
# WHMCS_DEV_API_IDENTIFIER="your_dev_whmcs_api_identifier"
# WHMCS_DEV_API_SECRET="your_dev_whmcs_api_secret"
# WHMCS_DEV_API_ACCESS_KEY="your_dev_whmcs_api_access_key"
# Salesforce Integration (use your actual credentials)
SF_LOGIN_URL="https://asolutions.my.salesforce.com"
SF_CLIENT_ID="your_salesforce_client_id"
@ -91,4 +100,3 @@ NODE_OPTIONS="--no-deprecation"
# 1. Run: pnpm dev:start
# 2. Frontend and Backend run locally (outside containers) for hot-reloading
# 3. Only database and cache services run in containers

View File

@ -5,7 +5,6 @@ import { AuthService } from "./auth.service";
import { LocalAuthGuard } from "./guards/local-auth.guard";
import { AuthThrottleGuard } from "./guards/auth-throttle.guard";
import { ApiTags, ApiOperation, ApiResponse } from "@nestjs/swagger";
import { SignupDto } from "./dto/signup.dto";
import { RequestPasswordResetDto } from "./dto/request-password-reset.dto";
import { ResetPasswordDto } from "./dto/reset-password.dto";
import { ChangePasswordDto } from "./dto/change-password.dto";
@ -14,6 +13,7 @@ import { SetPasswordDto } from "./dto/set-password.dto";
import { ValidateSignupDto } from "./dto/validate-signup.dto";
import { AccountStatusRequestDto, AccountStatusResponseDto } from "./dto/account-status.dto";
import { Public } from "./decorators/public.decorator";
import { SignupDto } from "./dto/signup.dto";
@ApiTags("auth")
@Controller("auth")
@ -41,6 +41,17 @@ export class AuthController {
return this.authService.healthCheck();
}
@Public()
@Post("signup-preflight")
@UseGuards(AuthThrottleGuard)
@Throttle({ default: { limit: 10, ttl: 900000 } })
@HttpCode(200)
@ApiOperation({ summary: "Validate full signup data without creating anything" })
@ApiResponse({ status: 200, description: "Preflight results with next action guidance" })
async signupPreflight(@Body() body: SignupDto) {
return this.authService.signupPreflight(body);
}
@Public()
@Post("account-status")
@ApiOperation({ summary: "Get account status by email" })

View File

@ -26,6 +26,7 @@ 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 {
@ -42,6 +43,7 @@ export class AuthService {
private auditService: AuditService,
private tokenBlacklistService: TokenBlacklistService,
private emailService: EmailService,
private prisma: PrismaService,
@Inject(Logger) private readonly logger: Logger
) {}
@ -200,16 +202,13 @@ export class AuthService {
// Enhanced input validation
this.validateSignupData(signupData);
// Check if a portal user already exists
// Check if a portal user already exists (do not create anything yet)
const existingUser: PrismaUser | null = await this.usersService.findByEmailInternal(email);
if (existingUser) {
// Determine whether the user has a completed mapping (registered) or not
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,
@ -221,7 +220,7 @@ export class AuthService {
throw new ConflictException(message);
}
// Hash password with environment-based configuration
// Hash password with environment-based configuration (computed ahead, used after WHMCS success)
const saltRoundsConfig = this.configService.get<string | number>("BCRYPT_ROUNDS", 12);
const saltRounds =
typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig;
@ -237,24 +236,10 @@ export class AuthService {
);
}
// 1. Create user in portal
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
// 1. Create client in WHMCS first (no portal user is created yet)
let whmcsClient: { clientId: number };
try {
// 2.0 Pre-check for existing WHMCS client by email to avoid duplicates and guide UX
// 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) {
@ -348,51 +333,58 @@ export class AuthService {
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,
// 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 };
});
// 4. Do not update Salesforce Account fields from the portal. Salesforce stays authoritative.
// Fetch sanitized user response
const freshUser = await this.usersService.findByIdInternal(createdUserId);
// Log successful signup
await this.auditService.logAuthEvent(
AuditAction.SIGNUP,
user.id,
createdUserId,
{ email, whmcsClientId: whmcsClient.clientId },
request,
true
);
// Generate JWT token
const tokens = this.generateTokens(user);
const tokens = this.generateTokens({ id: createdUserId, email });
return {
user: this.sanitizeUser(user),
user: this.sanitizeUser(
freshUser ?? ({ id: createdUserId, email } as unknown as PrismaUser)
),
...tokens,
};
} catch (error) {
@ -777,7 +769,7 @@ export class AuthService {
// Create SSO token using custom redirect for better compatibility
const ssoDestination = "sso:custom_redirect";
const ssoRedirectPath = destination || "clientarea.php";
const ssoRedirectPath = this.sanitizeWhmcsRedirectPath(destination);
const result = await this.whmcsService.createSsoToken(
mapping.whmcsClientId,
@ -798,6 +790,30 @@ export class AuthService {
}
}
/**
* 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
@ -985,6 +1001,121 @@ export class AuthService {
};
}
/**
* 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;
}
private validateSignupData(signupData: SignupDto) {
const { email, password, firstName, lastName } = signupData;

View File

@ -74,10 +74,12 @@ export class SignupDto {
@IsString()
company?: string;
@ApiProperty({ example: "+1-555-123-4567", required: false })
@IsOptional()
@ApiProperty({ example: "+81-90-1234-5678", description: "Contact phone number" })
@IsString()
phone?: string;
@Matches(/^[+]?[0-9\s\-()]{7,20}$/, {
message: "Phone number must contain 7-20 digits and may include +, spaces, dashes, and parentheses",
})
phone: string;
@ApiProperty({ example: "CN-0012345", description: "Customer Number (SF Number)" })
@IsString()

View File

@ -35,6 +35,14 @@ export const envSchema = z.object({
WHMCS_API_SECRET: z.string().optional(),
WHMCS_API_ACCESS_KEY: z.string().optional(),
WHMCS_WEBHOOK_SECRET: z.string().optional(),
// WHMCS Dev-only overrides (used when NODE_ENV !== 'production')
WHMCS_DEV_BASE_URL: z.string().url().optional(),
WHMCS_DEV_API_IDENTIFIER: z.string().optional(),
WHMCS_DEV_API_SECRET: z.string().optional(),
WHMCS_DEV_API_ACCESS_KEY: z.string().optional(),
WHMCS_DEV_WEBHOOK_SECRET: z.string().optional(),
WHMCS_DEV_ADMIN_USERNAME: z.string().optional(),
WHMCS_DEV_ADMIN_PASSWORD_MD5: z.string().optional(),
// Optional elevated admin credentials for privileged WHMCS actions (eg. AcceptOrder)
WHMCS_ADMIN_USERNAME: z.string().optional(),
// Expect MD5 hash of the admin password (preferred). Alias supported for compatibility.

View File

@ -89,13 +89,14 @@ async function bootstrap() {
});
// Global validation pipe with enhanced security
const exposeValidation = configService.get("EXPOSE_VALIDATION_ERRORS", "false") === "true";
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
forbidUnknownValues: true,
disableErrorMessages: configService.get("NODE_ENV") === "production",
disableErrorMessages: !exposeValidation && configService.get("NODE_ENV") === "production",
validationError: {
target: false,
value: false,

View File

@ -46,24 +46,83 @@ export class WhmcsConnectionService {
@Inject(Logger) private readonly logger: Logger,
private readonly configService: ConfigService
) {
// Helper: read the first defined value across a list of keys
const getFirst = (keys: Array<string | undefined>): string | undefined => {
for (const key of keys) {
if (!key) continue;
const v = this.configService.get<string | undefined>(key);
if (v && `${v}`.length > 0) return v;
const raw = process.env[key];
if (raw && `${raw}`.length > 0) return raw;
}
return undefined;
};
const nodeEnv = this.configService.get<string>("NODE_ENV", "development");
const isDev = nodeEnv !== "production"; // treat anything non-prod as dev/test
// Prefer explicit DEV variables when running in non-production
// Resolve and normalize base URL (trim trailing slashes)
const rawBaseUrl = getFirst([
isDev ? "WHMCS_DEV_BASE_URL" : undefined,
"WHMCS_BASE_URL",
]) || "";
const baseUrl = rawBaseUrl.replace(/\/+$/, "");
const identifier = getFirst([
isDev ? "WHMCS_DEV_API_IDENTIFIER" : undefined,
"WHMCS_API_IDENTIFIER",
]) || "";
const secret = getFirst([
isDev ? "WHMCS_DEV_API_SECRET" : undefined,
"WHMCS_API_SECRET",
]) || "";
const adminUsername = getFirst([
isDev ? "WHMCS_DEV_ADMIN_USERNAME" : undefined,
"WHMCS_ADMIN_USERNAME",
]);
const adminPasswordHash = getFirst([
isDev ? "WHMCS_DEV_ADMIN_PASSWORD_MD5" : undefined,
"WHMCS_ADMIN_PASSWORD_MD5",
"WHMCS_ADMIN_PASSWORD_HASH",
]);
const accessKey = getFirst([
isDev ? "WHMCS_DEV_API_ACCESS_KEY" : undefined,
"WHMCS_API_ACCESS_KEY",
]);
this.config = {
baseUrl: this.configService.get<string>("WHMCS_BASE_URL", ""),
identifier: this.configService.get<string>("WHMCS_API_IDENTIFIER", ""),
secret: this.configService.get<string>("WHMCS_API_SECRET", ""),
baseUrl,
identifier,
secret,
timeout: this.configService.get<number>("WHMCS_API_TIMEOUT", 30000),
retryAttempts: this.configService.get<number>("WHMCS_API_RETRY_ATTEMPTS", 1),
retryDelay: this.configService.get<number>("WHMCS_API_RETRY_DELAY", 1000),
adminUsername: this.configService.get<string | undefined>("WHMCS_ADMIN_USERNAME"),
adminPasswordHash:
this.configService.get<string | undefined>("WHMCS_ADMIN_PASSWORD_MD5") ||
this.configService.get<string | undefined>("WHMCS_ADMIN_PASSWORD_HASH"),
adminUsername,
adminPasswordHash,
};
// Optional API Access Key (used by some WHMCS deployments alongside API Credentials)
this.accessKey = this.configService.get<string | undefined>("WHMCS_API_ACCESS_KEY");
this.accessKey = accessKey;
if (isDev) {
const usingDev = [baseUrl, identifier, secret].every(v => !!v) && process.env.WHMCS_DEV_BASE_URL;
if (usingDev) {
this.logger.debug("Using WHMCS DEV environment variables (development mode)");
}
}
this.validateConfig();
}
/** Expose the resolved base URL for helper services (SSO URL resolution) */
getBaseUrl(): string {
return this.config.baseUrl;
}
private validateConfig(): void {
const requiredFields = ["baseUrl", "identifier", "secret"];
const missingFields = requiredFields.filter(

View File

@ -189,9 +189,11 @@ export class WhmcsPaymentService {
const response = await this.connectionService.createSsoToken(params);
const url = this.resolveRedirectUrl(response.redirect_url);
this.debugLogRedirectHost(url);
const result = {
url: response.redirect_url,
expiresAt: new Date(Date.now() + 3600000).toISOString(), // 1 hour
url,
expiresAt: new Date(Date.now() + 60000).toISOString(), // ~60 seconds (per WHMCS spec)
};
this.logger.log(`Created payment SSO token for client ${clientId}, invoice ${invoiceId}`, {
@ -247,4 +249,36 @@ export class WhmcsPaymentService {
transformProduct(whmcsProduct: Record<string, unknown>): unknown {
return this.dataTransformer.transformProduct(whmcsProduct);
}
/**
* Normalize WHMCS SSO redirect URLs to absolute using configured base URL.
*/
private resolveRedirectUrl(redirectUrl: string): string {
if (!redirectUrl) return redirectUrl;
const base = this.connectionService.getBaseUrl().replace(/\/+$/, "");
const isAbsolute = /^https?:\/\//i.test(redirectUrl);
if (!isAbsolute) {
const path = redirectUrl.replace(/^\/+/, "");
return `${base}/${path}`;
}
return redirectUrl;
}
/**
* Debug helper: log only the host of the SSO URL (never the token) in non-production.
*/
private debugLogRedirectHost(url: string): void {
if (process.env.NODE_ENV === "production") return;
try {
const target = new URL(url);
const base = new URL(this.connectionService.getBaseUrl());
this.logger.debug("WHMCS Payment SSO redirect host", {
redirectHost: target.host,
redirectOrigin: target.origin,
baseOrigin: base.origin,
});
} catch {
// Ignore parse errors silently
}
}
}

View File

@ -28,9 +28,11 @@ export class WhmcsSsoService {
const response = await this.connectionService.createSsoToken(params);
const url = this.resolveRedirectUrl(response.redirect_url);
this.debugLogRedirectHost(url);
const result = {
url: response.redirect_url,
expiresAt: new Date(Date.now() + 3600000).toISOString(), // 1 hour
url,
expiresAt: new Date(Date.now() + 60000).toISOString(), // ~60 seconds (per WHMCS spec)
};
this.logger.log(`Created SSO token for client ${clientId}`, {
@ -82,8 +84,10 @@ export class WhmcsSsoService {
const response = await this.connectionService.createSsoToken(params);
// Return the 60s, one-time URL
return response.redirect_url;
// Return the 60s, one-time URL (resolved to absolute)
const url = this.resolveRedirectUrl(response.redirect_url);
this.debugLogRedirectHost(url);
return url;
}
/**
@ -101,9 +105,11 @@ export class WhmcsSsoService {
const response = await this.connectionService.createSsoToken(params);
const url = this.resolveRedirectUrl(response.redirect_url);
this.debugLogRedirectHost(url);
const result = {
url: response.redirect_url,
expiresAt: new Date(Date.now() + 3600000).toISOString(), // 1 hour
url,
expiresAt: new Date(Date.now() + 60000).toISOString(), // ~60 seconds
};
this.logger.log(`Created admin SSO token for client ${clientId}`, {
@ -154,9 +160,11 @@ export class WhmcsSsoService {
const response = await this.connectionService.createSsoToken(ssoParams);
const url = this.resolveRedirectUrl(response.redirect_url);
this.debugLogRedirectHost(url);
const result = {
url: response.redirect_url,
expiresAt: new Date(Date.now() + 3600000).toISOString(), // 1 hour
url,
expiresAt: new Date(Date.now() + 60000).toISOString(), // ~60 seconds
};
this.logger.log(`Created module SSO token for client ${clientId}`, {
@ -175,4 +183,38 @@ export class WhmcsSsoService {
throw error;
}
}
/**
* Ensure the returned redirect URL is absolute and points to the active WHMCS base URL.
* WHMCS typically returns an absolute URL, but we normalize in case it's relative.
*/
private resolveRedirectUrl(redirectUrl: string): string {
if (!redirectUrl) return redirectUrl;
const base = this.connectionService.getBaseUrl().replace(/\/+$/, "");
const isAbsolute = /^https?:\/\//i.test(redirectUrl);
if (!isAbsolute) {
const path = redirectUrl.replace(/^\/+/, "");
return `${base}/${path}`;
}
// Absolute URL returned by WHMCS — return as-is
return redirectUrl;
}
/**
* Debug helper: log only the host of the SSO URL (never the token) in non-production.
*/
private debugLogRedirectHost(url: string): void {
if (process.env.NODE_ENV === "production") return;
try {
const target = new URL(url);
const base = new URL(this.connectionService.getBaseUrl());
this.logger.debug("WHMCS SSO redirect host", {
redirectHost: target.host,
redirectOrigin: target.origin,
baseOrigin: base.origin,
});
} catch {
// Ignore parse errors silently
}
}
}

View File

@ -29,6 +29,9 @@ RATE_LIMIT_LIMIT=100
AUTH_RATE_LIMIT_TTL=900000
AUTH_RATE_LIMIT_LIMIT=3
# Validation error visibility (set true to show field-level errors to clients)
EXPOSE_VALIDATION_ERRORS=false
# WHMCS Credentials
WHMCS_BASE_URL=https://accounts.asolutions.co.jp
WHMCS_API_IDENTIFIER=
@ -88,3 +91,5 @@ NODE_OPTIONS=--max-old-space-size=512
# NOTE: Frontend (Next.js) uses a separate env file (portal-frontend.env)
# Do not include NEXT_PUBLIC_* variables here.