barsa 88b9ac0a19 Enhance authentication and CSRF protection mechanisms
- Introduced optional JWT issuer and audience configurations in the JoseJwtService for improved token validation.
- Updated CSRF middleware to streamline token validation and enhance security measures.
- Added new environment variables for JWT issuer and audience, allowing for more flexible authentication setups.
- Refactored CSRF controller and middleware to improve token handling and security checks.
- Cleaned up and standardized cookie paths for access and refresh tokens in the AuthController.
- Enhanced error handling in the TokenBlacklistService to manage Redis availability more effectively.
2025-12-12 15:00:11 +09:00

306 lines
10 KiB
TypeScript

import {
Controller,
Post,
Body,
UseGuards,
UseInterceptors,
Get,
Req,
HttpCode,
UsePipes,
Res,
} from "@nestjs/common";
import type { Request, Response } from "express";
import { RateLimitGuard, RateLimit } from "@bff/core/rate-limiting/index.js";
import { AuthFacade } from "@bff/modules/auth/application/auth.facade.js";
import { LocalAuthGuard } from "./guards/local-auth.guard.js";
import {
FailedLoginThrottleGuard,
type RequestWithRateLimit,
} from "./guards/failed-login-throttle.guard.js";
import { LoginResultInterceptor } from "./interceptors/login-result.interceptor.js";
import { Public } from "../../decorators/public.decorator.js";
import { ZodValidationPipe } from "nestjs-zod";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js";
import { SalesforceWriteThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-write-throttle.guard.js";
import { JoseJwtService } from "../../infra/token/jose-jwt.service.js";
import type { UserAuth } from "@customer-portal/domain/customer";
import { extractAccessTokenFromRequest } from "../../utils/token-from-request.util.js";
// 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 calculateCookieMaxAge = (isoTimestamp: string): number => {
const expiresAt = Date.parse(isoTimestamp);
if (Number.isNaN(expiresAt)) {
return 0;
}
return Math.max(0, expiresAt - Date.now());
};
const ACCESS_COOKIE_PATH = "/api";
const REFRESH_COOKIE_PATH = "/api/auth/refresh";
@Controller("auth")
export class AuthController {
constructor(
private authFacade: AuthFacade,
private readonly jwtService: JoseJwtService
) {}
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: ACCESS_COOKIE_PATH,
});
res.setSecureCookie("refresh_token", tokens.refreshToken, {
maxAge: refreshMaxAge,
path: REFRESH_COOKIE_PATH,
});
}
private clearAuthCookies(res: Response): void {
// Clear current cookie paths
res.setSecureCookie("access_token", "", { maxAge: 0, path: ACCESS_COOKIE_PATH });
res.setSecureCookie("refresh_token", "", { maxAge: 0, path: REFRESH_COOKIE_PATH });
// Backward-compat: clear legacy cookies that were set on `/`
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(RateLimitGuard, SalesforceReadThrottleGuard)
@RateLimit({ 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(RateLimitGuard, SalesforceReadThrottleGuard)
@RateLimit({ 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(RateLimitGuard, SalesforceWriteThrottleGuard)
@RateLimit({ 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 = extractAccessTokenFromRequest(req);
let userId = req.user?.id;
if (!userId && token) {
const payload = await this.jwtService.verifyAllowExpired<{ sub?: string }>(token);
if (payload?.sub) {
userId = payload.sub;
}
}
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")
@UseGuards(RateLimitGuard)
@RateLimit({ 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(RateLimitGuard, SalesforceWriteThrottleGuard)
@RateLimit({ 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(RateLimitGuard)
@RateLimit({ 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")
@UseGuards(RateLimitGuard)
@RateLimit({ 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)
@UseGuards(RateLimitGuard)
@RateLimit({ limit: 5, ttl: 900 }) // 5 attempts per 15 minutes (standard for password operations)
@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
this.clearAuthCookies(res);
return { message: "Password reset successful" };
}
@Post("change-password")
@UseGuards(RateLimitGuard)
@RateLimit({ limit: 5, ttl: 300 }) // 5 attempts per 5 minutes
@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: UserAuth }) {
return { isAuthenticated: true, user: req.user };
}
@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);
}
}