using System.Security.Cryptography; using System.Text; using Fake.Application; using Fake.DependencyInjection; using Microsoft.AspNetCore.Http; using Oms.Domain.CustomersAggregate.Entities; using Oms.Domain.CustomersAggregate.Enums; using Oms.Domain.CustomersAggregate.Repositories; namespace Oms.Application.Shared; /// /// 客户 OpenAPI 机机鉴权:appKey + timestamp + HMAC-SHA256(sign),Secret 仅本地参与签名。 /// public class CustomerOpenApiAuthHelper : IScopedDependency { public const string AppKeyHeaderName = "appKey"; public const string TimestampHeaderName = "timestamp"; public const string SignHeaderName = "sign"; /// 签名时间窗(秒)。 public const int TimestampSkewSeconds = 300; private readonly ICustomerOpenApiCredentialRepository _credentialRepository; private readonly ICustomerRepository _customerRepository; private readonly IHttpContextAccessor _httpContextAccessor; public CustomerOpenApiAuthHelper( ICustomerOpenApiCredentialRepository credentialRepository, ICustomerRepository customerRepository, IHttpContextAccessor httpContextAccessor) { _credentialRepository = credentialRepository; _customerRepository = customerRepository; _httpContextAccessor = httpContextAccessor; } /// /// 校验 Header 签名并解析主客户。 /// stringToSign = appKey + "\n" + timestamp + "\n" + sha256Hex(rawBody) /// sign = hex(HMAC-SHA256(appSecret, stringToSign)) /// public async Task<(Customer Customer, CustomerOpenApiCredential Credential)> ResolveSignedAsync( string? appKey, string? timestamp, string? sign, string? rawBody, CancellationToken cancellationToken = default) { var key = appKey?.Trim(); var tsRaw = timestamp?.Trim(); var signRaw = sign?.Trim(); if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(tsRaw) || string.IsNullOrWhiteSpace(signRaw)) { throw new BusinessException("凭证无效"); } if (!long.TryParse(tsRaw, out var unixSeconds)) { throw new BusinessException("请求已过期"); } var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); if (Math.Abs(now - unixSeconds) > TimestampSkewSeconds) { throw new BusinessException("请求已过期"); } var credential = await _credentialRepository.GetByAppKeyAsync(key, cancellationToken); if (credential == null || !credential.Enabled || string.IsNullOrWhiteSpace(credential.AppSecret)) { throw new BusinessException("凭证无效"); } var expectedSign = ComputeSign(key, tsRaw, rawBody ?? string.Empty, credential.AppSecret); if (!FixedTimeEqualsUtf8(expectedSign, signRaw.ToLowerInvariant())) { throw new BusinessException("签名无效"); } var customer = await _customerRepository.FirstOrDefaultAsync( x => x.Id == credential.CustomerId, cancellationToken); if (customer == null || customer.ParentCustomerId.HasValue || customer.Status != CustomerStatus.Normal) { throw new BusinessException("凭证无效"); } credential.LastUsedAt = DateTime.Now; credential.LastUsedIp = ResolveClientIp(); await _credentialRepository.UpdateAsync(credential); return (customer, credential); } /// /// 客户端可复用:计算 sign(小写 hex)。 /// public static string ComputeSign(string appKey, string timestamp, string rawBody, string appSecret) { var bodyHash = Sha256Hex(rawBody ?? string.Empty); var stringToSign = $"{appKey}\n{timestamp}\n{bodyHash}"; return HmacSha256Hex(appSecret, stringToSign); } private static string Sha256Hex(string text) { var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(text)); return Convert.ToHexString(bytes).ToLowerInvariant(); } private static string HmacSha256Hex(string secret, string content) { var keyBytes = Encoding.UTF8.GetBytes(secret); var contentBytes = Encoding.UTF8.GetBytes(content); var hash = HMACSHA256.HashData(keyBytes, contentBytes); return Convert.ToHexString(hash).ToLowerInvariant(); } private static bool FixedTimeEqualsUtf8(string? expected, string actual) { if (string.IsNullOrEmpty(expected)) { return false; } var left = Encoding.UTF8.GetBytes(expected); var right = Encoding.UTF8.GetBytes(actual); return left.Length == right.Length && CryptographicOperations.FixedTimeEquals(left, right); } private string? ResolveClientIp() { var http = _httpContextAccessor.HttpContext; if (http == null) { return null; } var forwarded = http.Request.Headers["X-Forwarded-For"].FirstOrDefault(); if (!string.IsNullOrWhiteSpace(forwarded)) { return forwarded.Split(',')[0].Trim(); } return http.Connection.RemoteIpAddress?.ToString(); } }