diff --git a/src/Oms.Application/Dtos/Orders/OpenApiParcelCreateInput.cs b/src/Oms.Application/Dtos/Orders/OpenApiParcelCreateInput.cs index ccf50e9..dcfb695 100644 --- a/src/Oms.Application/Dtos/Orders/OpenApiParcelCreateInput.cs +++ b/src/Oms.Application/Dtos/Orders/OpenApiParcelCreateInput.cs @@ -8,9 +8,6 @@ namespace Oms.Application.Dtos.Orders; /// public class OpenApiParcelCreateInput { - /// 请求标识(幂等,可选)。 - public string? RequestId { get; set; } - /// 客户单号。 public string? ClientNo { get; set; } @@ -107,7 +104,7 @@ public class OpenApiParcelCreateInput public OrderParcelCreateInput ToOrderParcelCreateInput(int customerId) => new() { - RequestId = RequestId, + RequestId = null, ClientNo = ClientNo, TrackNo = TrackNo, ServiceNo = ServiceNo, diff --git a/src/Oms.Application/Services/OpenApi/OpenApiService.cs b/src/Oms.Application/Services/OpenApi/OpenApiService.cs index 2b5c66f..0076fbb 100644 --- a/src/Oms.Application/Services/OpenApi/OpenApiService.cs +++ b/src/Oms.Application/Services/OpenApi/OpenApiService.cs @@ -1,5 +1,8 @@ +using System.Text; +using System.Text.Json; using Fake.Application; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Oms.Application.Dtos.OpenApi; using Oms.Application.Dtos.Orders; @@ -11,11 +14,16 @@ namespace Oms.Application.Services.OpenApi; /// /// 对外开放 API(Swagger 文档分组:openApi)。 /// 统一响应:{ code, msg, data }。 -/// 客户机机鉴权:请求头 appKey + appSecret(勿放 Body,避免落日志)。 +/// 客户下单鉴权:Header appKey + timestamp + sign(HMAC-SHA256,5 分钟窗口);Secret 不上传。 /// [ApiExplorerSettings(GroupName = "openApi")] public class OpenApiService : ApplicationService { + private static readonly JsonSerializerOptions BodyJsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + private CustomerOpenApiAuthHelper AuthHelper => LazyServiceProvider.GetRequiredService(); @@ -25,6 +33,9 @@ public class OpenApiService : ApplicationService private OrderService OrderService => LazyServiceProvider.GetRequiredService(); + private IHttpContextAccessor HttpContextAccessor => + LazyServiceProvider.GetRequiredService(); + /// /// 推送揽收件重尺并按当前产品计算应收应付(上游 itfx 鉴权后转发,无需客户凭证)。 /// @@ -52,20 +63,27 @@ public class OpenApiService : ApplicationService /// /// 创建订单包裹(仅自发货)。 - /// 鉴权:Header appKey、appSecret;客户归属强制取自凭证,Body 不可指定 customerId。 + /// Header:appKey、timestamp(Unix 秒)、sign。 + /// sign = hex(HMAC-SHA256(appSecret, appKey + "\n" + timestamp + "\n" + sha256Hex(rawBody)))。 /// [AllowAnonymous] [HttpPost("/openapi/parcel/create")] public async Task> CreateParcelAsync( [FromHeader(Name = CustomerOpenApiAuthHelper.AppKeyHeaderName)] string? appKey, - [FromHeader(Name = CustomerOpenApiAuthHelper.AppSecretHeaderName)] string? appSecret, - [FromBody] OpenApiParcelCreateInput input) + [FromHeader(Name = CustomerOpenApiAuthHelper.TimestampHeaderName)] string? timestamp, + [FromHeader(Name = CustomerOpenApiAuthHelper.SignHeaderName)] string? sign) { try { - ArgumentNullException.ThrowIfNull(input); + var rawBody = await ReadRawBodyAsync(); + var (customer, _) = await AuthHelper.ResolveSignedAsync(appKey, timestamp, sign, rawBody); + + var input = JsonSerializer.Deserialize(rawBody, BodyJsonOptions); + if (input == null) + { + throw new BusinessException("请求体不能为空"); + } - var (customer, _) = await AuthHelper.ResolveAsync(appKey, appSecret); var data = await OrderService.CreateParcelAsync(input.ToOrderParcelCreateInput(customer.Id)); return OpenApiEnvelope.Ok(data); } @@ -73,6 +91,10 @@ public class OpenApiService : ApplicationService { return OpenApiEnvelope.Fail(ex.Message); } + catch (JsonException) + { + return OpenApiEnvelope.Fail("请求体格式错误"); + } catch (Exception ex) { return OpenApiEnvelope.Fail( @@ -80,4 +102,25 @@ public class OpenApiService : ApplicationService "500"); } } + + private async Task ReadRawBodyAsync() + { + var http = HttpContextAccessor.HttpContext + ?? throw new BusinessException("凭证无效"); + var request = http.Request; + request.EnableBuffering(); + if (request.Body.CanSeek) + { + request.Body.Position = 0; + } + + using var reader = new StreamReader(request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true); + var raw = await reader.ReadToEndAsync(); + if (request.Body.CanSeek) + { + request.Body.Position = 0; + } + + return raw; + } } diff --git a/src/Oms.Application/Shared/CustomerOpenApiAuthHelper.cs b/src/Oms.Application/Shared/CustomerOpenApiAuthHelper.cs index b65658c..0306371 100644 --- a/src/Oms.Application/Shared/CustomerOpenApiAuthHelper.cs +++ b/src/Oms.Application/Shared/CustomerOpenApiAuthHelper.cs @@ -10,12 +10,16 @@ using Oms.Domain.CustomersAggregate.Repositories; namespace Oms.Application.Shared; /// -/// 客户 OpenAPI 机机鉴权:按请求头 AppKey + AppSecret 解析主客户。 +/// 客户 OpenAPI 机机鉴权:appKey + timestamp + HMAC-SHA256(sign),Secret 仅本地参与签名。 /// public class CustomerOpenApiAuthHelper : IScopedDependency { public const string AppKeyHeaderName = "appKey"; - public const string AppSecretHeaderName = "appSecret"; + public const string TimestampHeaderName = "timestamp"; + public const string SignHeaderName = "sign"; + + /// 签名时间窗(秒)。 + public const int TimestampSkewSeconds = 300; private readonly ICustomerOpenApiCredentialRepository _credentialRepository; private readonly ICustomerRepository _customerRepository; @@ -32,38 +36,52 @@ public class CustomerOpenApiAuthHelper : IScopedDependency } /// - /// 从当前请求头读取 appKey / appSecret 并鉴权。 + /// 校验 Header 签名并解析主客户。 + /// stringToSign = appKey + "\n" + timestamp + "\n" + sha256Hex(rawBody) + /// sign = hex(HMAC-SHA256(appSecret, stringToSign)) /// - public Task<(Customer Customer, CustomerOpenApiCredential Credential)> ResolveFromHeadersAsync( - CancellationToken cancellationToken = default) - { - var http = _httpContextAccessor.HttpContext - ?? throw new BusinessException("凭证无效"); - var appKey = http.Request.Headers[AppKeyHeaderName].FirstOrDefault(); - var appSecret = http.Request.Headers[AppSecretHeaderName].FirstOrDefault(); - return ResolveAsync(appKey, appSecret, cancellationToken); - } - - public async Task<(Customer Customer, CustomerOpenApiCredential Credential)> ResolveAsync( + public async Task<(Customer Customer, CustomerOpenApiCredential Credential)> ResolveSignedAsync( string? appKey, - string? appSecret, + string? timestamp, + string? sign, + string? rawBody, CancellationToken cancellationToken = default) { var key = appKey?.Trim(); - var secret = appSecret?.Trim(); - if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(secret)) + 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 - || !FixedTimeEqualsUtf8(credential.AppSecret, secret)) + || 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); @@ -81,6 +99,30 @@ public class CustomerOpenApiAuthHelper : IScopedDependency 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)) diff --git a/src/SimpleAdmin.Api/SimpleAdminApiModule.cs b/src/SimpleAdmin.Api/SimpleAdminApiModule.cs index 0d0e2a1..b24c7ff 100644 --- a/src/SimpleAdmin.Api/SimpleAdminApiModule.cs +++ b/src/SimpleAdmin.Api/SimpleAdminApiModule.cs @@ -167,6 +167,17 @@ public class SimpleAdminApiModule : FakeModule // 路由 - 必须在认证和授权之前 app.UseRouting(); + // OpenAPI 验签需读取原始 Body,允许二次读流 + app.Use(async (ctx, next) => + { + if (ctx.Request.Path.StartsWithSegments("/openapi")) + { + ctx.Request.EnableBuffering(); + } + + await next(); + }); + // 认证和授权 app.UseAuthentication(); app.UseAuthorization();