feat(api): 开发auth、login等接口,规范格式化
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import { Inject, Injectable, OnModuleDestroy } 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';
|
||||
type Limit = { ip: number; email?: number };
|
||||
|
||||
@Injectable()
|
||||
export class AuthRateLimiter implements 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.
|
||||
this.redis = new Redis(config.REDIS_URL, {
|
||||
enableOfflineQueue: false,
|
||||
maxRetriesPerRequest: 1,
|
||||
});
|
||||
}
|
||||
|
||||
public async consume(
|
||||
action: AuthAction,
|
||||
identifiers: { ip: string | null; email?: string },
|
||||
): Promise<void> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async onModuleDestroy(): Promise<void> {
|
||||
await this.redis.quit();
|
||||
}
|
||||
|
||||
private async consumeBucket(
|
||||
action: AuthAction,
|
||||
dimension: 'ip' | 'email',
|
||||
identifier: string,
|
||||
limit: number,
|
||||
): Promise<void> {
|
||||
const digest = createHmac(
|
||||
'sha256',
|
||||
this.config.AUTH_RATE_LIMIT_KEY_SECRET,
|
||||
)
|
||||
.update(identifier)
|
||||
.digest('base64url');
|
||||
// The raw IP/email never becomes part of a Redis key or a loggable identifier.
|
||||
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.
|
||||
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;
|
||||
// Authentication fails closed: silently bypassing a broken shared limiter enables brute-force attacks.
|
||||
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,
|
||||
};
|
||||
}
|
||||
return { ip: this.config.AUTH_REFRESH_IP_LIMIT };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user