barsa c79488a6a4 Enhance Salesforce request handling and metrics tracking
- Introduced new metrics for daily API usage, including dailyApiLimit and dailyUsagePercent, to monitor API consumption effectively.
- Added route-level metrics tracking to capture request success and failure rates for better performance insights.
- Implemented degradation state management to handle rate limits and usage thresholds, improving resilience during high load.
- Enhanced SalesforceRequestQueueService to include detailed logging for route-level metrics, aiding in debugging and performance analysis.
- Updated Salesforce module to export new SalesforceReadThrottleGuard for improved request rate limiting across services.
- Refactored various services to utilize the new metrics and logging features, ensuring consistent behavior and improved maintainability.
2025-11-06 13:26:30 +09:00

340 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { JwtService } from "@nestjs/jwt";
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,
type RequestWithRateLimit,
} from "./guards/failed-login-throttle.guard";
import { LoginResultInterceptor } from "./interceptors/login-result.interceptor";
import { Public } from "../../decorators/public.decorator";
import { ZodValidationPipe } from "@customer-portal/validation/nestjs";
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard";
// Import Zod schemas from domain
import {
signupRequestSchema,
passwordResetRequestSchema,
passwordResetSchema,
setPasswordRequestSchema,
linkWhmcsRequestSchema,
changePasswordRequestSchema,
validateSignupRequestSchema,
accountStatusRequestSchema,
ssoLinkRequestSchema,
checkPasswordNeededRequestSchema,
refreshTokenRequestSchema,
checkPasswordNeededResponseSchema,
linkWhmcsResponseSchema,
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 readonly jwtService: JwtService) {}
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: "/" });
}
private applyAuthRateLimitHeaders(req: RequestWithRateLimit, res: Response): void {
FailedLoginThrottleGuard.applyRateLimitHeaders(req, res);
}
@Public()
@Post("validate-signup")
@UseGuards(AuthThrottleGuard, SalesforceReadThrottleGuard)
@Throttle({ default: { limit: 20, ttl: 600 } }) // 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, SalesforceReadThrottleGuard)
@Throttle({ default: { limit: 20, ttl: 600 } }) // 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: 900 } }) // 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: RequestWithUser & RequestWithRateLimit,
@Res({ passthrough: true }) res: Response
) {
const result = await this.authFacade.login(req.user, req);
this.setAuthCookies(res, result.tokens);
this.applyAuthRateLimitHeaders(req, res);
return result;
}
@Public()
@Post("logout")
async logout(
@Req() req: RequestWithCookies & { user?: { id: string } },
@Res({ passthrough: true }) res: Response
) {
const token = extractTokenFromRequest(req);
let userId = req.user?.id;
if (!userId && token) {
try {
const payload = await this.jwtService.verifyAsync<{ sub?: string }>(token, {
ignoreExpiration: true,
});
if (payload?.sub) {
userId = payload.sub;
}
} catch (error) {
// Ignore verification errors we still want to clear client cookies.
}
}
await this.authFacade.logout(userId, token, req as Request);
// Always clear cookies, even if session expired
this.clearAuthCookies(res);
return { message: "Logout successful" };
}
@Public()
@Post("refresh")
@Throttle({ default: { limit: 10, ttl: 300 } }) // 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: 600 } }) // 5 attempts per 10 minutes per IP (industry standard)
@UsePipes(new ZodValidationPipe(linkWhmcsRequestSchema))
async linkWhmcs(@Body() linkData: LinkWhmcsRequest, @Req() _req: Request) {
const result = await this.authFacade.linkWhmcsUser(linkData);
return linkWhmcsResponseSchema.parse(result);
}
@Public()
@Post("set-password")
@UseGuards(AuthThrottleGuard)
@Throttle({ default: { limit: 5, ttl: 600 } }) // 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) {
const response = await this.authFacade.checkPasswordNeeded(data.email);
return checkPasswordNeededResponseSchema.parse(response);
}
@Public()
@Post("request-password-reset")
@Throttle({ default: { limit: 5, ttl: 900 } }) // 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: 300 } })
@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);
}
}