feat(api): 开发平台、门店相关代码
This commit is contained in:
@@ -1,4 +1,9 @@
|
||||
import { Inject, Injectable, OnModuleDestroy } from '@nestjs/common';
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { Redis } from 'ioredis';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
@@ -8,17 +13,23 @@ type AuthAction = 'login' | 'register' | 'refresh';
|
||||
type Limit = { ip: number; email?: number };
|
||||
|
||||
@Injectable()
|
||||
export class AuthRateLimiter implements OnModuleDestroy {
|
||||
export class AuthRateLimiter implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly redis: Redis;
|
||||
|
||||
public constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) {
|
||||
// Redis makes counters shared across API processes instead of resetting per instance.
|
||||
// Redis 让限流计数在多个 API 实例间共享,不会随单个实例重启清零。
|
||||
this.redis = new Redis(config.REDIS_URL, {
|
||||
enableOfflineQueue: false,
|
||||
lazyConnect: true,
|
||||
maxRetriesPerRequest: 1,
|
||||
});
|
||||
}
|
||||
|
||||
public async onModuleInit(): Promise<void> {
|
||||
// 在开始接收认证请求前完成连接,避免首个请求命中 Redis 连接竞态而被误判为 503。
|
||||
await this.redis.connect();
|
||||
}
|
||||
|
||||
public async consume(
|
||||
action: AuthAction,
|
||||
identifiers: { ip: string | null; email?: string },
|
||||
@@ -41,6 +52,10 @@ export class AuthRateLimiter implements OnModuleDestroy {
|
||||
}
|
||||
|
||||
public async onModuleDestroy(): Promise<void> {
|
||||
if (this.redis.status !== 'ready') {
|
||||
this.redis.disconnect();
|
||||
return;
|
||||
}
|
||||
await this.redis.quit();
|
||||
}
|
||||
|
||||
@@ -56,10 +71,10 @@ export class AuthRateLimiter implements OnModuleDestroy {
|
||||
)
|
||||
.update(identifier)
|
||||
.digest('base64url');
|
||||
// The raw IP/email never becomes part of a Redis key or a loggable identifier.
|
||||
// Redis key 只保存 IP/邮箱的 HMAC,不暴露可被日志或运维工具读取的原文。
|
||||
const key = `atlas:auth-rate-limit:${action}:${dimension}:${digest}`;
|
||||
try {
|
||||
// INCR + first-write EXPIRE must be atomic or concurrent requests could leave a key unexpired.
|
||||
// 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,
|
||||
@@ -75,7 +90,7 @@ export class AuthRateLimiter implements OnModuleDestroy {
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) throw error;
|
||||
// Authentication fails closed: silently bypassing a broken shared limiter enables brute-force attacks.
|
||||
// 认证限流故障时必须拒绝请求;静默跳过会扩大暴力破解风险。
|
||||
throw new ApiError(
|
||||
503,
|
||||
'AUTH_RATE_LIMIT_UNAVAILABLE',
|
||||
|
||||
Reference in New Issue
Block a user