import { Inject, Injectable, OnModuleDestroy, OnModuleInit, } from '@nestjs/common'; import { createHmac } from 'node:crypto'; import { Redis } from 'ioredis'; import { ApiError } from '../common/api-error.js'; import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js'; type AuthAction = 'login' | 'register' | 'refresh' | 'change_password'; type Limit = { ip: number; email?: number; user?: number }; @Injectable() export class AuthRateLimiter implements OnModuleInit, OnModuleDestroy { private readonly redis: Redis; public constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) { // Redis 让限流计数在多个 API 实例间共享,不会随单个实例重启清零。 this.redis = new Redis(config.REDIS_URL, { enableOfflineQueue: false, lazyConnect: true, maxRetriesPerRequest: 1, }); } public async onModuleInit(): Promise { // 在开始接收认证请求前完成连接,避免首个请求命中 Redis 连接竞态而被误判为 503。 await this.redis.connect(); } public async consume( action: AuthAction, identifiers: { ip: string | null; email?: string; userId?: string }, ): Promise { const limit = this.limitFor(action); await this.consumeBucket( action, 'ip', identifiers.ip ?? 'unknown', limit.ip, ); if (identifiers.email && limit.email) { await this.consumeBucket( action, 'email', identifiers.email, limit.email, ); } if (identifiers.userId && limit.user) { await this.consumeBucket( action, 'user', identifiers.userId, limit.user, ); } } public async onModuleDestroy(): Promise { if (this.redis.status !== 'ready') { this.redis.disconnect(); return; } await this.redis.quit(); } private async consumeBucket( action: AuthAction, dimension: 'ip' | 'email' | 'user', identifier: string, limit: number, ): Promise { const digest = createHmac( 'sha256', this.config.AUTH_RATE_LIMIT_KEY_SECRET, ) .update(identifier) .digest('base64url'); // Redis key 只保存 IP/邮箱的 HMAC,不暴露可被日志或运维工具读取的原文。 const key = `atlas:auth-rate-limit:${action}:${dimension}:${digest}`; try { // INCR 与首次 EXPIRE 必须原子执行,避免并发下产生永不过期的限流 key。 const count = await this.redis.eval( "local value = redis.call('INCR', KEYS[1]); if value == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]); end; return value;", 1, key, this.config.AUTH_RATE_LIMIT_WINDOW_SECONDS, ); if (Number(count) > limit) { throw new ApiError( 429, 'AUTH_RATE_LIMITED', '请求过于频繁,请稍后重试', ); } } catch (error) { if (error instanceof ApiError) throw error; // 认证限流故障时必须拒绝请求;静默跳过会扩大暴力破解风险。 throw new ApiError( 503, 'AUTH_RATE_LIMIT_UNAVAILABLE', '认证服务暂时不可用', ); } } private limitFor(action: AuthAction): Limit { if (action === 'login') { return { ip: this.config.AUTH_LOGIN_IP_LIMIT, email: this.config.AUTH_LOGIN_EMAIL_LIMIT, }; } if (action === 'register') { return { ip: this.config.AUTH_REGISTER_IP_LIMIT, email: this.config.AUTH_REGISTER_EMAIL_LIMIT, }; } if (action === 'change_password') { return { ip: this.config.AUTH_CHANGE_PASSWORD_IP_LIMIT, user: this.config.AUTH_CHANGE_PASSWORD_USER_LIMIT, }; } return { ip: this.config.AUTH_REFRESH_IP_LIMIT }; } }