import { Controller, Post, Body, UseGuards, Get, Req, HttpCode, UsePipes, Res, } from "@nestjs/common"; import type { Request, Response } from "express"; import { Throttle } from "@nestjs/throttler"; import { AuthService } from "./auth.service"; import { LocalAuthGuard } from "./guards/local-auth.guard"; import { AuthThrottleGuard } from "./guards/auth-throttle.guard"; import { ApiTags, ApiOperation, ApiResponse, ApiOkResponse } from "@nestjs/swagger"; 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, type SignupRequestInput, type PasswordResetRequestInput, type PasswordResetInput, type SetPasswordRequestInput, type LinkWhmcsRequestInput, type ChangePasswordRequestInput, type ValidateSignupRequestInput, type AccountStatusRequestInput, type SsoLinkRequestInput, type CheckPasswordNeededRequestInput, refreshTokenRequestSchema, type RefreshTokenRequestInput, } from "@customer-portal/domain"; import type { AuthTokens } from "@customer-portal/domain"; type RequestWithCookies = Request & { cookies?: Record }; const EXTRACT_BEARER = (req: RequestWithCookies): string | undefined => { const authHeader = req.headers?.authorization; if (typeof authHeader === "string" && authHeader.startsWith("Bearer ")) { return authHeader.slice(7); } if (Array.isArray(authHeader) && authHeader.length > 0 && authHeader[0]?.startsWith("Bearer ")) { return authHeader[0]?.slice(7); } return undefined; }; const extractTokenFromRequest = (req: RequestWithCookies): string | undefined => { const headerToken = EXTRACT_BEARER(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()); }; @ApiTags("auth") @Controller("auth") export class AuthController { constructor(private authService: AuthService) {} 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: 10, ttl: 900000 } }) // 10 validations per 15 minutes per IP @UsePipes(new ZodValidationPipe(validateSignupRequestSchema)) @ApiOperation({ summary: "Validate customer number for signup" }) @ApiResponse({ status: 200, description: "Validation successful" }) @ApiResponse({ status: 409, description: "Customer already has account" }) @ApiResponse({ status: 400, description: "Customer number not found" }) @ApiResponse({ status: 429, description: "Too many validation attempts" }) async validateSignup(@Body() validateData: ValidateSignupRequestInput, @Req() req: Request) { return this.authService.validateSignup(validateData, req); } @Public() @Get("health-check") @ApiOperation({ summary: "Check auth service health and integrations" }) @ApiResponse({ status: 200, description: "Health check results" }) async healthCheck() { return this.authService.healthCheck(); } @Public() @Post("signup-preflight") @UseGuards(AuthThrottleGuard) @Throttle({ default: { limit: 10, ttl: 900000 } }) @UsePipes(new ZodValidationPipe(signupRequestSchema)) @HttpCode(200) @ApiOperation({ summary: "Validate full signup data without creating anything" }) @ApiResponse({ status: 200, description: "Preflight results with next action guidance" }) async signupPreflight(@Body() signupData: SignupRequestInput) { return this.authService.signupPreflight(signupData); } @Public() @Post("account-status") @UsePipes(new ZodValidationPipe(accountStatusRequestSchema)) @ApiOperation({ summary: "Get account status by email" }) @ApiOkResponse({ description: "Account status" }) async accountStatus(@Body() body: AccountStatusRequestInput) { return this.authService.getAccountStatus(body.email); } @Public() @Post("signup") @UseGuards(AuthThrottleGuard) @Throttle({ default: { limit: 3, ttl: 900000 } }) // 3 signups per 15 minutes per IP @UsePipes(new ZodValidationPipe(signupRequestSchema)) @ApiOperation({ summary: "Create new user account" }) @ApiResponse({ status: 201, description: "User created successfully" }) @ApiResponse({ status: 409, description: "User already exists" }) @ApiResponse({ status: 429, description: "Too many signup attempts" }) async signup( @Body() signupData: SignupRequestInput, @Req() req: Request, @Res({ passthrough: true }) res: Response ) { const result = await this.authService.signup(signupData, req); this.setAuthCookies(res, result.tokens); return result; } @Public() @UseGuards(LocalAuthGuard, AuthThrottleGuard) @Throttle({ default: { limit: 5, ttl: 900000 } }) // 5 login attempts per 15 minutes per IP+UA @Post("login") @ApiOperation({ summary: "Authenticate user" }) @ApiResponse({ status: 200, description: "Login successful" }) @ApiResponse({ status: 401, description: "Invalid credentials" }) @ApiResponse({ status: 429, description: "Too many login attempts" }) async login( @Req() req: Request & { user: { id: string; email: string; role: string } }, @Res({ passthrough: true }) res: Response ) { const result = await this.authService.login(req.user, req); this.setAuthCookies(res, result.tokens); return result; } @Post("logout") @ApiOperation({ summary: "Logout user" }) @ApiResponse({ status: 200, description: "Logout successful" }) async logout( @Req() req: RequestWithCookies & { user: { id: string } }, @Res({ passthrough: true }) res: Response ) { const token = extractTokenFromRequest(req); await this.authService.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)) @ApiOperation({ summary: "Refresh access token using refresh token" }) @ApiResponse({ status: 200, description: "Token refreshed successfully" }) @ApiResponse({ status: 401, description: "Invalid refresh token" }) @ApiResponse({ status: 429, description: "Too many refresh attempts" }) async refreshToken( @Body() body: RefreshTokenRequestInput, @Req() req: RequestWithCookies, @Res({ passthrough: true }) res: Response ) { const refreshToken = body.refreshToken ?? req.cookies?.refresh_token; const result = await this.authService.refreshTokens(refreshToken, { deviceId: body.deviceId, userAgent: req.headers["user-agent"], }); this.setAuthCookies(res, result.tokens); return result; } @Public() @Post("link-whmcs") @UseGuards(AuthThrottleGuard) @Throttle({ default: { limit: 3, ttl: 900000 } }) // 3 attempts per 15 minutes per IP @UsePipes(new ZodValidationPipe(linkWhmcsRequestSchema)) @ApiOperation({ summary: "Link existing WHMCS user" }) @ApiResponse({ status: 200, description: "WHMCS account linked successfully", }) @ApiResponse({ status: 401, description: "Invalid WHMCS credentials" }) @ApiResponse({ status: 429, description: "Too many link attempts" }) async linkWhmcs(@Body() linkData: LinkWhmcsRequestInput, @Req() _req: Request) { return this.authService.linkWhmcsUser(linkData); } @Public() @Post("set-password") @UseGuards(AuthThrottleGuard) @Throttle({ default: { limit: 3, ttl: 300000 } }) // 3 attempts per 5 minutes per IP+UA @UsePipes(new ZodValidationPipe(setPasswordRequestSchema)) @ApiOperation({ summary: "Set password for linked user" }) @ApiResponse({ status: 200, description: "Password set successfully" }) @ApiResponse({ status: 401, description: "User not found" }) @ApiResponse({ status: 429, description: "Too many password attempts" }) async setPassword( @Body() setPasswordData: SetPasswordRequestInput, @Req() _req: Request, @Res({ passthrough: true }) res: Response ) { const result = await this.authService.setPassword(setPasswordData); this.setAuthCookies(res, result.tokens); return result; } @Public() @Post("check-password-needed") @UsePipes(new ZodValidationPipe(checkPasswordNeededRequestSchema)) @HttpCode(200) @ApiOperation({ summary: "Check if user needs to set password" }) @ApiResponse({ status: 200, description: "Password status checked" }) async checkPasswordNeeded(@Body() data: CheckPasswordNeededRequestInput) { return this.authService.checkPasswordNeeded(data.email); } @Public() @Post("request-password-reset") @Throttle({ default: { limit: 5, ttl: 900000 } }) @UsePipes(new ZodValidationPipe(passwordResetRequestSchema)) @ApiOperation({ summary: "Request password reset email" }) @ApiResponse({ status: 200, description: "Reset email sent if account exists" }) async requestPasswordReset(@Body() body: PasswordResetRequestInput) { await this.authService.requestPasswordReset(body.email); return { message: "If an account exists, a reset email has been sent" }; } @Public() @Post("reset-password") @Throttle({ default: { limit: 5, ttl: 900000 } }) @UsePipes(new ZodValidationPipe(passwordResetSchema)) @ApiOperation({ summary: "Reset password with token" }) @ApiResponse({ status: 200, description: "Password reset successful" }) async resetPassword(@Body() body: PasswordResetInput, @Res({ passthrough: true }) res: Response) { const result = await this.authService.resetPassword(body.token, body.password); this.setAuthCookies(res, result.tokens); return result; } @Post("change-password") @Throttle({ default: { limit: 5, ttl: 300000 } }) @UsePipes(new ZodValidationPipe(changePasswordRequestSchema)) @ApiOperation({ summary: "Change password (authenticated)" }) @ApiResponse({ status: 200, description: "Password changed successfully" }) async changePassword( @Req() req: Request & { user: { id: string } }, @Body() body: ChangePasswordRequestInput, @Res({ passthrough: true }) res: Response ) { const result = await this.authService.changePassword( req.user.id, body.currentPassword, body.newPassword, req ); this.setAuthCookies(res, result.tokens); return result; } @Get("me") @ApiOperation({ summary: "Get current authentication status" }) 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)) @ApiOperation({ summary: "Create SSO link to WHMCS" }) @ApiResponse({ status: 200, description: "SSO link created successfully" }) @ApiResponse({ status: 404, description: "User not found or not linked to WHMCS", }) async createSsoLink( @Req() req: Request & { user: { id: string } }, @Body() body: SsoLinkRequestInput ) { const destination = body?.destination; return this.authService.createSsoLink(req.user.id, destination); } }