306 lines
9.7 KiB
TypeScript
Raw Normal View History

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 CookieValue = string | undefined;
type RequestWithCookies = Omit<Request, "cookies"> & {
cookies?: Record<string, CookieValue>;
};
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);
}
}