import { CanActivate, ExecutionContext, ForbiddenException, HttpException, HttpStatus, Injectable, OnModuleDestroy, Optional, UnauthorizedException, } from '@nestjs/common'; import { randomUUID, 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 { openApiSignature, publicOpenApiFailure } from './open-api.protocol'; import { ProtocolLogsService } from '../protocol-logs/protocol-logs.service'; 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, @Optional() private readonly protocolLogs?: ProtocolLogsService, ) {} async canActivate(context: ExecutionContext) { const request = context.switchToHttp().getRequest(); request.openApiRequestId = 'req_' + randomUUID(); context .switchToHttp() .getResponse<{ setHeader: (name: string, value: string) => void }>() .setHeader('X-Request-Id', request.openApiRequestId); const startedAt = Date.now(); try { return await this.authenticate(context); } catch (error) { const failure = publicOpenApiFailure(error); this.protocolLogs?.record({ protocol: 'http', direction: 'client_to_platform', eventType: 'authentication', status: 'failed', requestId: request.openApiRequestId, resultCode: failure.code, durationMs: Date.now() - startedAt, detail: { method: request.method, path: '/api/openapi/v1/sms' }, }); throw error; } } private async authenticate(context: ExecutionContext) { const request = context.switchToHttp().getRequest(); 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 expected = openApiSignature( decryptSecret(credential.secretEncrypted), request.method, path, timestampText, nonce, request.rawBody, ); 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); }