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

432 lines
13 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 "@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";
2025-08-21 15:24:40 +09:00
import { TokenBlacklistService } from "./services/token-blacklist.service";
import { getErrorMessage } from "@bff/core/utils/error.util";
2025-08-22 17:02:49 +09:00
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";
2025-08-23 18:02:05 +09:00
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,
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 {
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);
}
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
);
const tokens = await this.tokenService.generateTokenPair(user, {
userAgent: request?.headers['user-agent'],
});
return {
user: sanitizeUser(user),
2025-09-18 16:40:43 +09:00
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);
}
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<{ id: string; email: string; role?: string } | null> {
const user = await this.usersService.findByEmailInternal(email);
2025-08-21 15:24:40 +09:00
if (!user) {
await this.auditService.logAuthEvent(
AuditAction.LOGIN_FAILED,
undefined,
{ 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,
{ 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,
{ 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 sanitized user object matching the return type
return {
id: user.id,
email: user.email,
role: user.role || undefined,
};
} else {
// Increment failed login attempts
2025-08-22 17:02:49 +09:00
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) },
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
{
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
{
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
/**
* 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 {
return sanitizeWhmcsRedirectPath(path);
}
2025-09-18 16:39:18 +09:00
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);
}
}