import { Controller, Post, Body, UseGuards, UseInterceptors, Get, Req, HttpCode, UsePipes, Res, } from "@nestjs/common"; import type { Request, Response } from "express"; import { Throttle } from "@nestjs/throttler"; import { AuthFacade } from "@bff/modules/auth/application/auth.facade"; import { LocalAuthGuard } from "./guards/local-auth.guard"; import { AuthThrottleGuard } from "./guards/auth-throttle.guard"; import { FailedLoginThrottleGuard } from "./guards/failed-login-throttle.guard"; import { LoginResultInterceptor } from "./interceptors/login-result.interceptor"; import { Public } from "../../decorators/public.decorator"; import { ZodValidationPipe } from "@bff/core/validation"; // Import Zod schemas from domain import { signupRequestSchema, passwordResetRequestSchema, passwordResetSchema, setPasswordRequestSchema, linkWhmcsRequestSchema, changePasswordRequestSchema, validateSignupRequestSchema, accountStatusRequestSchema, ssoLinkRequestSchema, checkPasswordNeededRequestSchema, refreshTokenRequestSchema, type SignupRequest, type PasswordResetRequest, type ResetPasswordRequest, type SetPasswordRequest, type LinkWhmcsRequest, type ChangePasswordRequest, type ValidateSignupRequest, type AccountStatusRequest, type SsoLinkRequest, type CheckPasswordNeededRequest, type RefreshTokenRequest, type AuthTokens, } from "@customer-portal/domain/auth"; type RequestWithCookies = Request & { cookies: Record; }; const resolveAuthorizationHeader = (req: RequestWithCookies): string | undefined => { const rawHeader = req.headers?.authorization; if (typeof rawHeader === "string") { return rawHeader; } if (Array.isArray(rawHeader)) { const headerValues: string[] = rawHeader; for (const candidate of headerValues) { if (typeof candidate === "string" && candidate.trim().length > 0) { return candidate; } } } return undefined; }; const extractBearerToken = (req: RequestWithCookies): string | undefined => { const authHeader = resolveAuthorizationHeader(req); if (authHeader && authHeader.startsWith("Bearer ")) { return authHeader.slice(7); } return undefined; }; const extractTokenFromRequest = (req: RequestWithCookies): string | undefined => { const headerToken = extractBearerToken(req); if (headerToken) { return headerToken; } const cookieToken = req.cookies?.access_token; return typeof cookieToken === "string" && cookieToken.length > 0 ? cookieToken : undefined; }; const calculateCookieMaxAge = (isoTimestamp: string): number => { const expiresAt = Date.parse(isoTimestamp); if (Number.isNaN(expiresAt)) { return 0; } return Math.max(0, expiresAt - Date.now()); }; @Controller("auth") export class AuthController { constructor(private authFacade: AuthFacade) {} private setAuthCookies(res: Response, tokens: AuthTokens): void { const accessMaxAge = calculateCookieMaxAge(tokens.expiresAt); const refreshMaxAge = calculateCookieMaxAge(tokens.refreshExpiresAt); res.setSecureCookie("access_token", tokens.accessToken, { maxAge: accessMaxAge, path: "/", }); res.setSecureCookie("refresh_token", tokens.refreshToken, { maxAge: refreshMaxAge, path: "/", }); } private clearAuthCookies(res: Response): void { res.setSecureCookie("access_token", "", { maxAge: 0, path: "/" }); res.setSecureCookie("refresh_token", "", { maxAge: 0, path: "/" }); } @Public() @Post("validate-signup") @UseGuards(AuthThrottleGuard) @Throttle({ default: { limit: 20, ttl: 600000 } }) // 20 validations per 10 minutes per IP @UsePipes(new ZodValidationPipe(validateSignupRequestSchema)) async validateSignup(@Body() validateData: ValidateSignupRequest, @Req() req: Request) { return this.authFacade.validateSignup(validateData, req); } @Public() @Get("health-check") async healthCheck() { return this.authFacade.healthCheck(); } @Public() @Post("signup-preflight") @UseGuards(AuthThrottleGuard) @Throttle({ default: { limit: 20, ttl: 600000 } }) // 20 validations per 10 minutes per IP @UsePipes(new ZodValidationPipe(signupRequestSchema)) @HttpCode(200) async signupPreflight(@Body() signupData: SignupRequest) { return this.authFacade.signupPreflight(signupData); } @Public() @Post("account-status") @UsePipes(new ZodValidationPipe(accountStatusRequestSchema)) async accountStatus(@Body() body: AccountStatusRequest) { return this.authFacade.getAccountStatus(body.email); } @Public() @Post("signup") @UseGuards(AuthThrottleGuard) @Throttle({ default: { limit: 5, ttl: 900000 } }) // 5 signups per 15 minutes per IP (reasonable for account creation) @UsePipes(new ZodValidationPipe(signupRequestSchema)) async signup( @Body() signupData: SignupRequest, @Req() req: Request, @Res({ passthrough: true }) res: Response ) { const result = await this.authFacade.signup(signupData, req); this.setAuthCookies(res, result.tokens); return result; } @Public() @UseGuards(LocalAuthGuard, FailedLoginThrottleGuard) @UseInterceptors(LoginResultInterceptor) @Post("login") async login( @Req() req: Request & { user: { id: string; email: string; role: string } }, @Res({ passthrough: true }) res: Response ) { const result = await this.authFacade.login(req.user, req); this.setAuthCookies(res, result.tokens); return result; } @Post("logout") async logout( @Req() req: RequestWithCookies & { user: { id: string } }, @Res({ passthrough: true }) res: Response ) { const token = extractTokenFromRequest(req); await this.authFacade.logout(req.user.id, token, req); this.clearAuthCookies(res); return { message: "Logout successful" }; } @Public() @Post("refresh") @Throttle({ default: { limit: 10, ttl: 300000 } }) // 10 attempts per 5 minutes per IP @UsePipes(new ZodValidationPipe(refreshTokenRequestSchema)) async refreshToken( @Body() body: RefreshTokenRequest, @Req() req: RequestWithCookies, @Res({ passthrough: true }) res: Response ) { const refreshToken = body.refreshToken ?? req.cookies?.refresh_token; const rawUserAgent = req.headers["user-agent"]; const userAgent = typeof rawUserAgent === "string" ? rawUserAgent : undefined; const result = await this.authFacade.refreshTokens(refreshToken, { deviceId: body.deviceId, userAgent, }); this.setAuthCookies(res, result.tokens); return result; } @Public() @Post("link-whmcs") @UseGuards(AuthThrottleGuard) @Throttle({ default: { limit: 5, ttl: 600000 } }) // 5 attempts per 10 minutes per IP (industry standard) @UsePipes(new ZodValidationPipe(linkWhmcsRequestSchema)) async linkWhmcs(@Body() linkData: LinkWhmcsRequest, @Req() _req: Request) { return this.authFacade.linkWhmcsUser(linkData); } @Public() @Post("set-password") @UseGuards(AuthThrottleGuard) @Throttle({ default: { limit: 5, ttl: 600000 } }) // 5 attempts per 10 minutes per IP+UA (industry standard) @UsePipes(new ZodValidationPipe(setPasswordRequestSchema)) async setPassword( @Body() setPasswordData: SetPasswordRequest, @Req() _req: Request, @Res({ passthrough: true }) res: Response ) { const result = await this.authFacade.setPassword(setPasswordData); this.setAuthCookies(res, result.tokens); return result; } @Public() @Post("check-password-needed") @UsePipes(new ZodValidationPipe(checkPasswordNeededRequestSchema)) @HttpCode(200) async checkPasswordNeeded(@Body() data: CheckPasswordNeededRequest) { return this.authFacade.checkPasswordNeeded(data.email); } @Public() @Post("request-password-reset") @Throttle({ default: { limit: 5, ttl: 900000 } }) // 5 attempts per 15 minutes (standard for password operations) @UsePipes(new ZodValidationPipe(passwordResetRequestSchema)) async requestPasswordReset(@Body() body: PasswordResetRequest, @Req() req: Request) { await this.authFacade.requestPasswordReset(body.email, req); return { message: "If an account exists, a reset email has been sent" }; } @Public() @Post("reset-password") @HttpCode(200) @UsePipes(new ZodValidationPipe(passwordResetSchema)) async resetPassword(@Body() body: ResetPasswordRequest, @Res({ passthrough: true }) res: Response) { await this.authFacade.resetPassword(body.token, body.password); // Clear auth cookies after password reset to force re-login res.clearCookie("access_token", { httpOnly: true, sameSite: "lax" }); res.clearCookie("refresh_token", { httpOnly: true, sameSite: "lax" }); return { message: "Password reset successful" }; } @Post("change-password") @Throttle({ default: { limit: 5, ttl: 300000 } }) @UsePipes(new ZodValidationPipe(changePasswordRequestSchema)) async changePassword( @Req() req: Request & { user: { id: string } }, @Body() body: ChangePasswordRequest, @Res({ passthrough: true }) res: Response ) { const result = await this.authFacade.changePassword(req.user.id, body, req); this.setAuthCookies(res, result.tokens); return result; } @Get("me") getAuthStatus(@Req() req: Request & { user: { id: string; email: string; role: string } }) { // Return basic auth info only - full profile should use /api/me return { isAuthenticated: true, user: { id: req.user.id, email: req.user.email, role: req.user.role, }, }; } @Post("sso-link") @UsePipes(new ZodValidationPipe(ssoLinkRequestSchema)) async createSsoLink( @Req() req: Request & { user: { id: string } }, @Body() body: SsoLinkRequest ) { const destination = body?.destination; return this.authFacade.createSsoLink(req.user.id, destination); } }