126 lines
7.1 KiB
TypeScript
126 lines
7.1 KiB
TypeScript
import { CanActivate, ExecutionContext, ForbiddenException, HttpException, HttpStatus, Injectable, OnModuleDestroy, UnauthorizedException } from '@nestjs/common';
|
|
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
|
import { isIP } from 'node:net';
|
|
import IORedis from 'ioredis';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { decryptSecret } from './open-api.crypto';
|
|
import type { OpenApiRequestLike } from './open-api.types';
|
|
import { SecurityDetectionService } from '../security-detection/security-detection.service';
|
|
|
|
@Injectable()
|
|
export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
|
|
private redis?: IORedis;
|
|
|
|
constructor(private readonly prisma: PrismaService, private readonly security: SecurityDetectionService) {}
|
|
|
|
async canActivate(context: ExecutionContext) {
|
|
const request = context.switchToHttp().getRequest<OpenApiRequestLike>();
|
|
const accessKey = header(request, 'x-app-key');
|
|
const timestampText = header(request, 'x-timestamp');
|
|
const nonce = header(request, 'x-nonce');
|
|
const suppliedSignature = header(request, 'x-signature')?.replace(/^sha256=/i, '');
|
|
if (!accessKey || !timestampText || !nonce || !suppliedSignature) {
|
|
await this.recordFailure('http_signature_failure', request, undefined, 'AUTH_HEADERS_MISSING');
|
|
throw new UnauthorizedException({ code: 'AUTH_HEADERS_MISSING', message: '缺少HTTP接口鉴权请求头' });
|
|
}
|
|
if (!/^[A-Za-z0-9_-]{8,128}$/.test(nonce)) {
|
|
await this.recordFailure('http_signature_failure', request, accessKey, 'NONCE_INVALID');
|
|
throw new UnauthorizedException({ code: 'NONCE_INVALID', message: 'X-Nonce 格式非法' });
|
|
}
|
|
const credential = await this.prisma.httpApiCredential.findUnique({
|
|
where: { accessKey },
|
|
include: { application: { include: { httpConfig: true, httpIpAllowlist: true } } },
|
|
});
|
|
if (!credential || credential.status !== 'active' || (credential.expiresAt && credential.expiresAt <= new Date())) {
|
|
await this.recordFailure('http_invalid_api_key', request, accessKey, 'CREDENTIAL_INVALID');
|
|
throw new UnauthorizedException({ code: 'CREDENTIAL_INVALID', message: '访问凭据无效或已失效' });
|
|
}
|
|
const config = credential.application.httpConfig;
|
|
if (!config?.enabled || credential.application.status !== 'active') {
|
|
throw new ForbiddenException({ code: 'HTTP_API_DISABLED', message: '该企业应用未开通HTTP接口' });
|
|
}
|
|
const timestamp = Number(timestampText);
|
|
if (!Number.isFinite(timestamp) || Math.abs(Date.now() - timestamp * 1000) > config.timestampToleranceSeconds * 1000) {
|
|
await this.recordFailure('http_signature_failure', request, accessKey, 'TIMESTAMP_EXPIRED');
|
|
throw new UnauthorizedException({ code: 'TIMESTAMP_EXPIRED', message: '请求时间戳已过期' });
|
|
}
|
|
const sourceIp = requestIp(request);
|
|
if (credential.application.httpIpAllowlist.length > 0 && (!sourceIp || !credential.application.httpIpAllowlist.some((item) => ipMatches(sourceIp, item.ipCidr)))) {
|
|
throw new ForbiddenException({ code: 'IP_NOT_ALLOWED', message: '当前IP不在HTTP接口白名单中' });
|
|
}
|
|
const path = (request.originalUrl ?? request.url ?? '').split('?')[0];
|
|
const bodyHash = createHash('sha256').update(request.rawBody ?? Buffer.from(JSON.stringify(request.body ?? {}))).digest('hex');
|
|
const signatureSource = [request.method.toUpperCase(), path, timestampText, nonce, bodyHash].join('\n');
|
|
const expected = createHmac('sha256', decryptSecret(credential.secretEncrypted)).update(signatureSource).digest('hex');
|
|
const expectedBuffer = Buffer.from(expected, 'hex');
|
|
const suppliedBuffer = /^[0-9a-f]{64}$/i.test(suppliedSignature) ? Buffer.from(suppliedSignature, 'hex') : Buffer.alloc(0);
|
|
if (expectedBuffer.length !== suppliedBuffer.length || !timingSafeEqual(expectedBuffer, suppliedBuffer)) {
|
|
await this.recordFailure('http_signature_failure', request, accessKey, 'SIGNATURE_INVALID');
|
|
throw new UnauthorizedException({ code: 'SIGNATURE_INVALID', message: '请求签名校验失败' });
|
|
}
|
|
const redis = this.getRedis();
|
|
const nonceAccepted = await redis.set(`openapi:nonce:${credential.id}:${nonce}`, '1', 'EX', config.timestampToleranceSeconds * 2, 'NX');
|
|
if (nonceAccepted !== 'OK') {
|
|
await this.recordFailure('http_replay_attempt', request, accessKey, 'NONCE_REPLAYED');
|
|
throw new UnauthorizedException({ code: 'NONCE_REPLAYED', message: 'X-Nonce 已使用' });
|
|
}
|
|
const second = Math.floor(Date.now() / 1000);
|
|
const qpsKey = `openapi:qps:${credential.applicationId}:${second}`;
|
|
const currentQps = await redis.incr(qpsKey);
|
|
if (currentQps === 1) await redis.expire(qpsKey, 2);
|
|
if (currentQps > config.qpsLimit) {
|
|
throw new HttpException({ code: 'QPS_LIMIT_EXCEEDED', message: 'HTTP接口QPS超限' }, HttpStatus.TOO_MANY_REQUESTS);
|
|
}
|
|
request.openApiAuth = {
|
|
application: credential.application,
|
|
config,
|
|
credentialId: credential.id,
|
|
accessKey,
|
|
sourceIp,
|
|
};
|
|
await this.prisma.httpApiCredential.update({ where: { id: credential.id }, data: { lastUsedAt: new Date(), lastUsedIp: sourceIp } });
|
|
return true;
|
|
}
|
|
|
|
onModuleDestroy() { this.redis?.disconnect(); }
|
|
|
|
private getRedis() {
|
|
this.redis ??= new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', { maxRetriesPerRequest: 1 });
|
|
return this.redis;
|
|
}
|
|
|
|
private async recordFailure(ruleCode: 'http_invalid_api_key' | 'http_signature_failure' | 'http_replay_attempt', request: OpenApiRequestLike, account: string | undefined, resultCode: string) {
|
|
const sourceIp = requestIp(request);
|
|
if (!sourceIp) return;
|
|
// 检测记录失败不能改变原鉴权响应,避免安全辅助链路放大为业务可用性事故。
|
|
await this.security.recordEvent({ ruleCode, sourceIp, account, resultCode, protocol: 'http', path: (request.originalUrl ?? request.url ?? '').split('?')[0] }).catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
function header(request: OpenApiRequestLike, name: string) {
|
|
const value = request.headers[name];
|
|
return Array.isArray(value) ? value[0] : value;
|
|
}
|
|
|
|
function requestIp(request: OpenApiRequestLike) {
|
|
const forwarded = header(request, 'x-forwarded-for')?.split(',')[0]?.trim();
|
|
const remoteAddress = request.socket?.remoteAddress?.replace(/^::ffff:/, '');
|
|
const trustedProxies = new Set((process.env.TRUSTED_PROXY_IPS ?? '127.0.0.1,::1').split(',').map((item) => item.trim()).filter(Boolean));
|
|
return (remoteAddress && trustedProxies.has(remoteAddress) ? forwarded : remoteAddress)?.replace(/^::ffff:/, '');
|
|
}
|
|
|
|
function ipMatches(ip: string, rule: string) {
|
|
const normalized = rule.trim();
|
|
if (!normalized.includes('/')) return ip === normalized;
|
|
const [network, bitsText] = normalized.split('/');
|
|
if (isIP(ip) !== 4 || isIP(network) !== 4) return false;
|
|
const bits = Number(bitsText);
|
|
if (!Number.isInteger(bits) || bits < 0 || bits > 32) return false;
|
|
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
|
|
return (ipv4(ip) & mask) === (ipv4(network) & mask);
|
|
}
|
|
|
|
function ipv4(value: string) {
|
|
return value.split('.').reduce((result, part) => ((result << 8) | Number(part)) >>> 0, 0);
|
|
}
|