2025-10-02 14:16:46 +09:00
|
|
|
import { Inject, Injectable, InternalServerErrorException } from "@nestjs/common";
|
|
|
|
|
import { ConfigService } from "@nestjs/config";
|
|
|
|
|
import { ThrottlerException } from "@nestjs/throttler";
|
|
|
|
|
import { Logger } from "nestjs-pino";
|
|
|
|
|
import type { Request } from "express";
|
|
|
|
|
import { RateLimiterRedis, RateLimiterRes } from "rate-limiter-flexible";
|
|
|
|
|
import { createHash } from "crypto";
|
|
|
|
|
import type { Redis } from "ioredis";
|
2025-10-02 18:47:30 +09:00
|
|
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
2025-10-02 14:16:46 +09:00
|
|
|
|
2025-10-02 18:47:30 +09:00
|
|
|
export interface RateLimitOutcome {
|
2025-10-02 14:16:46 +09:00
|
|
|
key: string;
|
|
|
|
|
remainingPoints: number;
|
|
|
|
|
consumedPoints: number;
|
|
|
|
|
msBeforeNext: number;
|
|
|
|
|
needsCaptcha: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
|
export class AuthRateLimitService {
|
|
|
|
|
private readonly loginLimiter: RateLimiterRedis;
|
|
|
|
|
|
|
|
|
|
private readonly refreshLimiter: RateLimiterRedis;
|
|
|
|
|
|
|
|
|
|
private readonly signupLimiter: RateLimiterRedis;
|
|
|
|
|
|
|
|
|
|
private readonly passwordResetLimiter: RateLimiterRedis;
|
|
|
|
|
|
|
|
|
|
private readonly loginCaptchaThreshold: number;
|
|
|
|
|
|
|
|
|
|
private readonly captchaAlwaysOn: boolean;
|
|
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
|
@Inject("REDIS_CLIENT") private readonly redis: Redis,
|
|
|
|
|
private readonly configService: ConfigService,
|
|
|
|
|
@Inject(Logger) private readonly logger: Logger
|
|
|
|
|
) {
|
|
|
|
|
const loginLimit = this.configService.get<number>("LOGIN_RATE_LIMIT_LIMIT", 5);
|
|
|
|
|
const loginTtlMs = this.configService.get<number>("LOGIN_RATE_LIMIT_TTL", 900000);
|
|
|
|
|
|
|
|
|
|
const signupLimit = this.configService.get<number>("SIGNUP_RATE_LIMIT_LIMIT", 5);
|
|
|
|
|
const signupTtlMs = this.configService.get<number>("SIGNUP_RATE_LIMIT_TTL", 900000);
|
|
|
|
|
|
2025-10-03 11:29:59 +09:00
|
|
|
const passwordResetLimit = this.configService.get<number>("PASSWORD_RESET_RATE_LIMIT_LIMIT", 5);
|
2025-10-02 18:47:30 +09:00
|
|
|
const passwordResetTtlMs = this.configService.get<number>(
|
|
|
|
|
"PASSWORD_RESET_RATE_LIMIT_TTL",
|
|
|
|
|
900000
|
|
|
|
|
);
|
2025-10-02 14:16:46 +09:00
|
|
|
|
|
|
|
|
const refreshLimit = this.configService.get<number>("AUTH_REFRESH_RATE_LIMIT_LIMIT", 10);
|
|
|
|
|
const refreshTtlMs = this.configService.get<number>("AUTH_REFRESH_RATE_LIMIT_TTL", 300000);
|
|
|
|
|
|
2025-10-03 11:29:59 +09:00
|
|
|
this.loginCaptchaThreshold = this.configService.get<number>("LOGIN_CAPTCHA_AFTER_ATTEMPTS", 3);
|
2025-10-02 14:16:46 +09:00
|
|
|
this.captchaAlwaysOn = this.configService.get("AUTH_CAPTCHA_ALWAYS_ON", "false") === "true";
|
|
|
|
|
|
|
|
|
|
this.loginLimiter = this.createLimiter("auth-login", loginLimit, loginTtlMs);
|
|
|
|
|
this.signupLimiter = this.createLimiter("auth-signup", signupLimit, signupTtlMs);
|
|
|
|
|
this.passwordResetLimiter = this.createLimiter(
|
|
|
|
|
"auth-password-reset",
|
|
|
|
|
passwordResetLimit,
|
|
|
|
|
passwordResetTtlMs
|
|
|
|
|
);
|
|
|
|
|
this.refreshLimiter = this.createLimiter("auth-refresh", refreshLimit, refreshTtlMs);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async consumeLoginAttempt(request: Request): Promise<RateLimitOutcome> {
|
|
|
|
|
const key = this.buildKey("login", request);
|
|
|
|
|
const result = await this.consume(this.loginLimiter, key, "login");
|
|
|
|
|
return {
|
|
|
|
|
...result,
|
|
|
|
|
needsCaptcha: this.captchaAlwaysOn || this.requiresCaptcha(result.consumedPoints),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async clearLoginAttempts(request: Request): Promise<void> {
|
|
|
|
|
const key = this.buildKey("login", request);
|
|
|
|
|
await this.deleteKey(this.loginLimiter, key, "login");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async consumeSignupAttempt(request: Request): Promise<RateLimitOutcome> {
|
|
|
|
|
const key = this.buildKey("signup", request);
|
|
|
|
|
const outcome = await this.consume(this.signupLimiter, key, "signup");
|
|
|
|
|
return { ...outcome, needsCaptcha: false };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async consumePasswordReset(request: Request): Promise<RateLimitOutcome> {
|
|
|
|
|
const key = this.buildKey("password-reset", request);
|
|
|
|
|
const outcome = await this.consume(this.passwordResetLimiter, key, "password-reset");
|
|
|
|
|
return { ...outcome, needsCaptcha: false };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async consumeRefreshAttempt(request: Request, refreshToken: string): Promise<RateLimitOutcome> {
|
|
|
|
|
const tokenHash = this.hashToken(refreshToken);
|
|
|
|
|
const key = this.buildKey("refresh", request, tokenHash);
|
|
|
|
|
return this.consume(this.refreshLimiter, key, "refresh");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getCaptchaHeaderValue(needsCaptcha: boolean): string {
|
|
|
|
|
if (needsCaptcha || this.captchaAlwaysOn) {
|
|
|
|
|
return "required";
|
|
|
|
|
}
|
|
|
|
|
return "optional";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private requiresCaptcha(consumedPoints: number): boolean {
|
|
|
|
|
if (this.loginCaptchaThreshold <= 0) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return consumedPoints >= this.loginCaptchaThreshold;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private buildKey(type: string, request: Request, suffix?: string): string {
|
|
|
|
|
const ip = this.extractIp(request);
|
|
|
|
|
const userAgent = request.headers["user-agent"] || "unknown";
|
|
|
|
|
const uaHash = createHash("sha256").update(String(userAgent)).digest("hex").slice(0, 16);
|
|
|
|
|
|
|
|
|
|
return ["auth", type, ip, uaHash, suffix].filter(Boolean).join(":");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private extractIp(request: Request): string {
|
|
|
|
|
const forwarded = request.headers["x-forwarded-for"];
|
|
|
|
|
const forwardedIp = Array.isArray(forwarded) ? forwarded[0] : forwarded;
|
|
|
|
|
const rawIp =
|
|
|
|
|
(typeof forwardedIp === "string" ? forwardedIp.split(",")[0]?.trim() : undefined) ||
|
|
|
|
|
(request.headers["x-real-ip"] as string | undefined) ||
|
|
|
|
|
request.socket?.remoteAddress ||
|
|
|
|
|
request.ip ||
|
|
|
|
|
"unknown";
|
|
|
|
|
|
|
|
|
|
return rawIp.replace(/^::ffff:/, "");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private createLimiter(prefix: string, limit: number, ttlMs: number): RateLimiterRedis {
|
|
|
|
|
const duration = Math.max(1, Math.ceil(ttlMs / 1000));
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
return new RateLimiterRedis({
|
|
|
|
|
storeClient: this.redis,
|
|
|
|
|
keyPrefix: prefix,
|
|
|
|
|
points: limit,
|
|
|
|
|
duration,
|
2025-10-02 18:47:30 +09:00
|
|
|
inMemoryBlockOnConsumed: limit + 1,
|
2025-10-02 14:16:46 +09:00
|
|
|
insuranceLimiter: undefined,
|
|
|
|
|
});
|
2025-10-02 18:47:30 +09:00
|
|
|
} catch (error: unknown) {
|
|
|
|
|
this.logger.error(
|
|
|
|
|
{ prefix, error: getErrorMessage(error) },
|
|
|
|
|
"Failed to initialize rate limiter"
|
|
|
|
|
);
|
2025-10-02 14:16:46 +09:00
|
|
|
throw new InternalServerErrorException("Rate limiter initialization failed");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async consume(
|
|
|
|
|
limiter: RateLimiterRedis,
|
|
|
|
|
key: string,
|
|
|
|
|
context: string
|
|
|
|
|
): Promise<RateLimitOutcome> {
|
|
|
|
|
try {
|
|
|
|
|
const res = await limiter.consume(key);
|
|
|
|
|
const consumedPoints = Math.max(0, limiter.points - res.remainingPoints);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
key,
|
|
|
|
|
remainingPoints: res.remainingPoints,
|
|
|
|
|
consumedPoints,
|
|
|
|
|
msBeforeNext: res.msBeforeNext,
|
|
|
|
|
needsCaptcha: false,
|
|
|
|
|
};
|
2025-10-02 18:47:30 +09:00
|
|
|
} catch (error: unknown) {
|
2025-10-02 14:16:46 +09:00
|
|
|
if (error instanceof RateLimiterRes) {
|
2025-10-02 18:47:30 +09:00
|
|
|
const retryAfterMs = error?.msBeforeNext ?? 0;
|
2025-10-02 14:16:46 +09:00
|
|
|
const message = this.buildThrottleMessage(context, retryAfterMs);
|
|
|
|
|
|
|
|
|
|
this.logger.warn({ key, context, retryAfterMs }, "Auth rate limit reached");
|
|
|
|
|
|
|
|
|
|
throw new ThrottlerException(message);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-02 18:47:30 +09:00
|
|
|
this.logger.error({ key, context, error: getErrorMessage(error) }, "Rate limiter failure");
|
2025-10-02 14:16:46 +09:00
|
|
|
throw new ThrottlerException("Authentication temporarily unavailable");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-03 11:29:59 +09:00
|
|
|
private async deleteKey(limiter: RateLimiterRedis, key: string, context: string): Promise<void> {
|
2025-10-02 14:16:46 +09:00
|
|
|
try {
|
|
|
|
|
await limiter.delete(key);
|
2025-10-02 18:47:30 +09:00
|
|
|
} catch (error: unknown) {
|
|
|
|
|
this.logger.warn(
|
|
|
|
|
{ key, context, error: getErrorMessage(error) },
|
|
|
|
|
"Failed to reset rate limiter key"
|
|
|
|
|
);
|
2025-10-02 14:16:46 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private buildThrottleMessage(context: string, retryAfterMs: number): string {
|
|
|
|
|
const seconds = Math.ceil(retryAfterMs / 1000);
|
|
|
|
|
if (seconds <= 60) {
|
|
|
|
|
return `Too many ${context} attempts. Try again in ${seconds}s.`;
|
|
|
|
|
}
|
|
|
|
|
const minutes = Math.floor(seconds / 60);
|
|
|
|
|
const remainingSeconds = seconds % 60;
|
2025-10-02 18:47:30 +09:00
|
|
|
const timeMessage = remainingSeconds ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
|
2025-10-02 14:16:46 +09:00
|
|
|
return `Too many ${context} attempts. Try again in ${timeMessage}.`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private hashToken(token: string): string {
|
|
|
|
|
return createHash("sha256").update(token).digest("hex");
|
|
|
|
|
}
|
|
|
|
|
}
|