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();