432 lines
13 KiB
TypeScript
432 lines
13 KiB
TypeScript
import {
|
|
Injectable,
|
|
UnauthorizedException,
|
|
ConflictException,
|
|
BadRequestException,
|
|
Inject,
|
|
} from "@nestjs/common";
|
|
import { JwtService } from "@nestjs/jwt";
|
|
import { ConfigService } from "@nestjs/config";
|
|
import * as bcrypt from "bcrypt";
|
|
import { UsersService } from "@bff/modules/users/users.service";
|
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
|
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
|
|
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
|
|
import { AuditService, AuditAction } from "@bff/infra/audit/audit.service";
|
|
import { TokenBlacklistService } from "./services/token-blacklist.service";
|
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
|
import { Logger } from "nestjs-pino";
|
|
import { sanitizeWhmcsRedirectPath } from "@bff/core/utils/sso.util";
|
|
import {
|
|
type SignupRequestInput,
|
|
type ValidateSignupRequestInput,
|
|
type LinkWhmcsRequestInput,
|
|
type SetPasswordRequestInput,
|
|
} from "@customer-portal/domain";
|
|
|
|
import type { User as PrismaUser } from "@prisma/client";
|
|
import type { Request } from "express";
|
|
import { PrismaService } from "@bff/infra/database/prisma.service";
|
|
import { AuthTokenService } from "./services/token.service";
|
|
import { SignupWorkflowService } from "./services/workflows/signup-workflow.service";
|
|
import { PasswordWorkflowService } from "./services/workflows/password-workflow.service";
|
|
import { WhmcsLinkWorkflowService } from "./services/workflows/whmcs-link-workflow.service";
|
|
import { sanitizeUser } from "./utils/sanitize-user.util";
|
|
|
|
@Injectable()
|
|
export class AuthService {
|
|
private readonly MAX_LOGIN_ATTEMPTS = 5;
|
|
private readonly LOCKOUT_DURATION_MINUTES = 15;
|
|
|
|
constructor(
|
|
private readonly usersService: UsersService,
|
|
private readonly mappingsService: MappingsService,
|
|
private readonly jwtService: JwtService,
|
|
private readonly configService: ConfigService,
|
|
private readonly whmcsService: WhmcsService,
|
|
private readonly salesforceService: SalesforceService,
|
|
private readonly auditService: AuditService,
|
|
private readonly tokenBlacklistService: TokenBlacklistService,
|
|
private readonly prisma: PrismaService,
|
|
private readonly signupWorkflow: SignupWorkflowService,
|
|
private readonly passwordWorkflow: PasswordWorkflowService,
|
|
private readonly whmcsLinkWorkflow: WhmcsLinkWorkflowService,
|
|
private readonly tokenService: AuthTokenService,
|
|
@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 {
|
|
health.whmcs = await this.whmcsService.healthCheck();
|
|
} 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: ValidateSignupRequestInput, request?: Request) {
|
|
return this.signupWorkflow.validateSignup(validateData, request);
|
|
}
|
|
|
|
async signup(signupData: SignupRequestInput, request?: Request) {
|
|
return this.signupWorkflow.signup(signupData, request);
|
|
}
|
|
|
|
async login(
|
|
user: {
|
|
id: string;
|
|
email: string;
|
|
role?: string;
|
|
passwordHash?: string | null;
|
|
failedLoginAttempts?: number | null;
|
|
lockedUntil?: Date | null;
|
|
},
|
|
request?: Request
|
|
) {
|
|
// Update last login time and reset failed attempts
|
|
await this.usersService.update(user.id, {
|
|
lastLoginAt: new Date(),
|
|
failedLoginAttempts: 0,
|
|
lockedUntil: null,
|
|
});
|
|
|
|
// Log successful login
|
|
await this.auditService.logAuthEvent(
|
|
AuditAction.LOGIN_SUCCESS,
|
|
user.id,
|
|
{ email: user.email },
|
|
request,
|
|
true
|
|
);
|
|
|
|
const tokens = await this.tokenService.generateTokenPair(user, {
|
|
userAgent: request?.headers['user-agent'],
|
|
});
|
|
return {
|
|
user: sanitizeUser(user),
|
|
tokens,
|
|
};
|
|
}
|
|
|
|
async linkWhmcsUser(linkData: LinkWhmcsRequestInput) {
|
|
return this.whmcsLinkWorkflow.linkWhmcsUser(linkData.email, linkData.password);
|
|
}
|
|
|
|
async checkPasswordNeeded(email: string) {
|
|
return this.passwordWorkflow.checkPasswordNeeded(email);
|
|
}
|
|
|
|
async setPassword(setPasswordData: SetPasswordRequestInput) {
|
|
return this.passwordWorkflow.setPassword(setPasswordData.email, setPasswordData.password);
|
|
}
|
|
|
|
async validateUser(
|
|
email: string,
|
|
password: string,
|
|
_request?: Request
|
|
): Promise<{ id: string; email: string; role?: string } | null> {
|
|
const user = await this.usersService.findByEmailInternal(email);
|
|
|
|
if (!user) {
|
|
await this.auditService.logAuthEvent(
|
|
AuditAction.LOGIN_FAILED,
|
|
undefined,
|
|
{ reason: "User not found" },
|
|
_request,
|
|
false,
|
|
"User not found"
|
|
);
|
|
return null;
|
|
}
|
|
|
|
// Check if account is locked
|
|
if (user.lockedUntil && user.lockedUntil > new Date()) {
|
|
await this.auditService.logAuthEvent(
|
|
AuditAction.LOGIN_FAILED,
|
|
user.id,
|
|
{ reason: "Account locked" },
|
|
_request,
|
|
false,
|
|
"Account is locked"
|
|
);
|
|
return null;
|
|
}
|
|
|
|
if (!user.passwordHash) {
|
|
await this.auditService.logAuthEvent(
|
|
AuditAction.LOGIN_FAILED,
|
|
user.id,
|
|
{ reason: "No password set" },
|
|
_request,
|
|
false,
|
|
"No password set"
|
|
);
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
|
|
|
|
if (isPasswordValid) {
|
|
// Return sanitized user object matching the return type
|
|
return {
|
|
id: user.id,
|
|
email: user.email,
|
|
role: user.role || undefined,
|
|
};
|
|
} else {
|
|
// Increment failed login attempts
|
|
await this.handleFailedLogin(user, _request);
|
|
return null;
|
|
}
|
|
} catch (error) {
|
|
this.logger.error("Password validation error", { userId: user.id, error: getErrorMessage(error) });
|
|
await this.auditService.logAuthEvent(
|
|
AuditAction.LOGIN_FAILED,
|
|
user.id,
|
|
{ error: getErrorMessage(error) },
|
|
_request,
|
|
false,
|
|
getErrorMessage(error)
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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();
|
|
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,
|
|
{
|
|
failedAttempts: newFailedAttempts,
|
|
lockedUntil: lockedUntil?.toISOString(),
|
|
},
|
|
_request,
|
|
false,
|
|
"Invalid password"
|
|
);
|
|
|
|
// Log account lock if applicable
|
|
if (isAccountLocked) {
|
|
await this.auditService.logAuthEvent(
|
|
AuditAction.ACCOUNT_LOCKED,
|
|
user.id,
|
|
{
|
|
lockDuration: this.LOCKOUT_DURATION_MINUTES,
|
|
lockedUntil: lockedUntil?.toISOString(),
|
|
},
|
|
_request,
|
|
false,
|
|
`Account locked for ${this.LOCKOUT_DURATION_MINUTES} minutes`
|
|
);
|
|
}
|
|
}
|
|
|
|
async logout(userId: string, token: string, _request?: Request): Promise<void> {
|
|
// Blacklist the token
|
|
await this.tokenBlacklistService.blacklistToken(token);
|
|
|
|
await this.auditService.logAuthEvent(AuditAction.LOGOUT, userId, {}, _request, true);
|
|
}
|
|
|
|
// Helper methods
|
|
/**
|
|
* Create SSO link to WHMCS for general access
|
|
*/
|
|
async createSsoLink(
|
|
userId: string,
|
|
destination?: string
|
|
): Promise<{ url: string; expiresAt: string }> {
|
|
try {
|
|
// Production-safe logging - no sensitive data
|
|
this.logger.log("Creating SSO link request");
|
|
|
|
// Get WHMCS client ID from user mapping
|
|
const mapping = await this.mappingsService.findByUserId(userId);
|
|
|
|
if (!mapping?.whmcsClientId) {
|
|
this.logger.warn("SSO link creation failed: No WHMCS mapping found");
|
|
throw new UnauthorizedException("WHMCS client mapping not found");
|
|
}
|
|
|
|
// Create SSO token using custom redirect for better compatibility
|
|
const ssoDestination = "sso:custom_redirect";
|
|
const ssoRedirectPath = this.sanitizeWhmcsRedirectPath(destination);
|
|
|
|
const result = await this.whmcsService.createSsoToken(
|
|
mapping.whmcsClientId,
|
|
ssoDestination,
|
|
ssoRedirectPath
|
|
);
|
|
|
|
this.logger.log("SSO link created successfully");
|
|
|
|
return result;
|
|
} catch (error) {
|
|
// Production-safe error logging - no sensitive data or stack traces
|
|
this.logger.error("SSO link creation failed", {
|
|
errorType: error instanceof Error ? error.constructor.name : "Unknown",
|
|
message: getErrorMessage(error),
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
return sanitizeWhmcsRedirectPath(path);
|
|
}
|
|
|
|
|
|
async requestPasswordReset(email: string): Promise<void> {
|
|
await this.passwordWorkflow.requestPasswordReset(email);
|
|
}
|
|
|
|
async resetPassword(token: string, newPassword: string) {
|
|
return this.passwordWorkflow.resetPassword(token, newPassword);
|
|
}
|
|
|
|
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,
|
|
request?: Request
|
|
) {
|
|
return this.passwordWorkflow.changePassword(userId, currentPassword, newPassword, request);
|
|
}
|
|
|
|
async refreshTokens(refreshToken: string, deviceInfo?: { deviceId?: string; userAgent?: string }) {
|
|
return this.tokenService.refreshTokens(refreshToken, deviceInfo);
|
|
}
|
|
|
|
/**
|
|
* Preflight validation for signup. No side effects.
|
|
* Returns a clear nextAction for the UI and detailed flags.
|
|
*/
|
|
async signupPreflight(signupData: SignupRequestInput) {
|
|
return this.signupWorkflow.signupPreflight(signupData);
|
|
}
|
|
|
|
}
|