feat: OpenAPI 鉴权改 HMAC 签名校验,并完善下单报文处理
All checks were successful
Build and Deploy / deploy (push) Successful in 1m0s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-09-30 17:33:46 +08:00
parent 87f4cebc9b
commit 1b6e91b09d
4 changed files with 121 additions and 28 deletions

View File

@ -8,9 +8,6 @@ namespace Oms.Application.Dtos.Orders;
/// </summary> /// </summary>
public class OpenApiParcelCreateInput public class OpenApiParcelCreateInput
{ {
/// <summary>请求标识(幂等,可选)。</summary>
public string? RequestId { get; set; }
/// <summary>客户单号。</summary> /// <summary>客户单号。</summary>
public string? ClientNo { get; set; } public string? ClientNo { get; set; }
@ -107,7 +104,7 @@ public class OpenApiParcelCreateInput
public OrderParcelCreateInput ToOrderParcelCreateInput(int customerId) => public OrderParcelCreateInput ToOrderParcelCreateInput(int customerId) =>
new() new()
{ {
RequestId = RequestId, RequestId = null,
ClientNo = ClientNo, ClientNo = ClientNo,
TrackNo = TrackNo, TrackNo = TrackNo,
ServiceNo = ServiceNo, ServiceNo = ServiceNo,

View File

@ -1,5 +1,8 @@
using System.Text;
using System.Text.Json;
using Fake.Application; using Fake.Application;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.OpenApi; using Oms.Application.Dtos.OpenApi;
using Oms.Application.Dtos.Orders; using Oms.Application.Dtos.Orders;
@ -11,11 +14,16 @@ namespace Oms.Application.Services.OpenApi;
/// <summary> /// <summary>
/// 对外开放 API(Swagger 文档分组:openApi)。 /// 对外开放 API(Swagger 文档分组:openApi)。
/// 统一响应:{ code, msg, data }。 /// 统一响应:{ code, msg, data }。
/// 客户机机鉴权:请求头 appKey + appSecret(勿放 Body,避免落日志)。 /// 客户下单鉴权:Header appKey + timestamp + sign(HMAC-SHA256,5 分钟窗口);Secret 不上传。
/// </summary> /// </summary>
[ApiExplorerSettings(GroupName = "openApi")] [ApiExplorerSettings(GroupName = "openApi")]
public class OpenApiService : ApplicationService public class OpenApiService : ApplicationService
{ {
private static readonly JsonSerializerOptions BodyJsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private CustomerOpenApiAuthHelper AuthHelper => private CustomerOpenApiAuthHelper AuthHelper =>
LazyServiceProvider.GetRequiredService<CustomerOpenApiAuthHelper>(); LazyServiceProvider.GetRequiredService<CustomerOpenApiAuthHelper>();
@ -25,6 +33,9 @@ public class OpenApiService : ApplicationService
private OrderService OrderService => private OrderService OrderService =>
LazyServiceProvider.GetRequiredService<OrderService>(); LazyServiceProvider.GetRequiredService<OrderService>();
private IHttpContextAccessor HttpContextAccessor =>
LazyServiceProvider.GetRequiredService<IHttpContextAccessor>();
/// <summary> /// <summary>
/// 推送揽收件重尺并按当前产品计算应收应付(上游 itfx 鉴权后转发,无需客户凭证)。 /// 推送揽收件重尺并按当前产品计算应收应付(上游 itfx 鉴权后转发,无需客户凭证)。
/// </summary> /// </summary>
@ -52,20 +63,27 @@ public class OpenApiService : ApplicationService
/// <summary> /// <summary>
/// 创建订单包裹(仅自发货)。 /// 创建订单包裹(仅自发货)。
/// 鉴权:Header appKey、appSecret;客户归属强制取自凭证,Body 不可指定 customerId。 /// Header:appKey、timestamp(Unix 秒)、sign。
/// sign = hex(HMAC-SHA256(appSecret, appKey + "\n" + timestamp + "\n" + sha256Hex(rawBody)))。
/// </summary> /// </summary>
[AllowAnonymous] [AllowAnonymous]
[HttpPost("/openapi/parcel/create")] [HttpPost("/openapi/parcel/create")]
public async Task<OpenApiEnvelope<OrderParcelCreateOutput>> CreateParcelAsync( public async Task<OpenApiEnvelope<OrderParcelCreateOutput>> CreateParcelAsync(
[FromHeader(Name = CustomerOpenApiAuthHelper.AppKeyHeaderName)] string? appKey, [FromHeader(Name = CustomerOpenApiAuthHelper.AppKeyHeaderName)] string? appKey,
[FromHeader(Name = CustomerOpenApiAuthHelper.AppSecretHeaderName)] string? appSecret, [FromHeader(Name = CustomerOpenApiAuthHelper.TimestampHeaderName)] string? timestamp,
[FromBody] OpenApiParcelCreateInput input) [FromHeader(Name = CustomerOpenApiAuthHelper.SignHeaderName)] string? sign)
{ {
try try
{ {
ArgumentNullException.ThrowIfNull(input); var rawBody = await ReadRawBodyAsync();
var (customer, _) = await AuthHelper.ResolveSignedAsync(appKey, timestamp, sign, rawBody);
var input = JsonSerializer.Deserialize<OpenApiParcelCreateInput>(rawBody, BodyJsonOptions);
if (input == null)
{
throw new BusinessException("请求体不能为空");
}
var (customer, _) = await AuthHelper.ResolveAsync(appKey, appSecret);
var data = await OrderService.CreateParcelAsync(input.ToOrderParcelCreateInput(customer.Id)); var data = await OrderService.CreateParcelAsync(input.ToOrderParcelCreateInput(customer.Id));
return OpenApiEnvelope<OrderParcelCreateOutput>.Ok(data); return OpenApiEnvelope<OrderParcelCreateOutput>.Ok(data);
} }
@ -73,6 +91,10 @@ public class OpenApiService : ApplicationService
{ {
return OpenApiEnvelope<OrderParcelCreateOutput>.Fail(ex.Message); return OpenApiEnvelope<OrderParcelCreateOutput>.Fail(ex.Message);
} }
catch (JsonException)
{
return OpenApiEnvelope<OrderParcelCreateOutput>.Fail("请求体格式错误");
}
catch (Exception ex) catch (Exception ex)
{ {
return OpenApiEnvelope<OrderParcelCreateOutput>.Fail( return OpenApiEnvelope<OrderParcelCreateOutput>.Fail(
@ -80,4 +102,25 @@ public class OpenApiService : ApplicationService
"500"); "500");
} }
} }
private async Task<string> 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;
}
} }

View File

@ -10,12 +10,16 @@ using Oms.Domain.CustomersAggregate.Repositories;
namespace Oms.Application.Shared; namespace Oms.Application.Shared;
/// <summary> /// <summary>
/// 客户 OpenAPI 机机鉴权:按请求头 AppKey + AppSecret 解析主客户。 /// 客户 OpenAPI 机机鉴权:appKey + timestamp + HMAC-SHA256(sign),Secret 仅本地参与签名。
/// </summary> /// </summary>
public class CustomerOpenApiAuthHelper : IScopedDependency public class CustomerOpenApiAuthHelper : IScopedDependency
{ {
public const string AppKeyHeaderName = "appKey"; public const string AppKeyHeaderName = "appKey";
public const string AppSecretHeaderName = "appSecret"; public const string TimestampHeaderName = "timestamp";
public const string SignHeaderName = "sign";
/// <summary>签名时间窗(秒)。</summary>
public const int TimestampSkewSeconds = 300;
private readonly ICustomerOpenApiCredentialRepository _credentialRepository; private readonly ICustomerOpenApiCredentialRepository _credentialRepository;
private readonly ICustomerRepository _customerRepository; private readonly ICustomerRepository _customerRepository;
@ -32,38 +36,52 @@ public class CustomerOpenApiAuthHelper : IScopedDependency
} }
/// <summary> /// <summary>
/// 从当前请求头读取 appKey / appSecret 并鉴权。 /// 校验 Header 签名并解析主客户。
/// stringToSign = appKey + "\n" + timestamp + "\n" + sha256Hex(rawBody)
/// sign = hex(HMAC-SHA256(appSecret, stringToSign))
/// </summary> /// </summary>
public Task<(Customer Customer, CustomerOpenApiCredential Credential)> ResolveFromHeadersAsync( public async Task<(Customer Customer, CustomerOpenApiCredential Credential)> ResolveSignedAsync(
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(
string? appKey, string? appKey,
string? appSecret, string? timestamp,
string? sign,
string? rawBody,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var key = appKey?.Trim(); var key = appKey?.Trim();
var secret = appSecret?.Trim(); var tsRaw = timestamp?.Trim();
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(secret)) var signRaw = sign?.Trim();
if (string.IsNullOrWhiteSpace(key)
|| string.IsNullOrWhiteSpace(tsRaw)
|| string.IsNullOrWhiteSpace(signRaw))
{ {
throw new BusinessException("凭证无效"); 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); var credential = await _credentialRepository.GetByAppKeyAsync(key, cancellationToken);
if (credential == null if (credential == null
|| !credential.Enabled || !credential.Enabled
|| !FixedTimeEqualsUtf8(credential.AppSecret, secret)) || string.IsNullOrWhiteSpace(credential.AppSecret))
{ {
throw new BusinessException("凭证无效"); 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( var customer = await _customerRepository.FirstOrDefaultAsync(
x => x.Id == credential.CustomerId, x => x.Id == credential.CustomerId,
cancellationToken); cancellationToken);
@ -81,6 +99,30 @@ public class CustomerOpenApiAuthHelper : IScopedDependency
return (customer, credential); return (customer, credential);
} }
/// <summary>
/// 客户端可复用:计算 sign(小写 hex)。
/// </summary>
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) private static bool FixedTimeEqualsUtf8(string? expected, string actual)
{ {
if (string.IsNullOrEmpty(expected)) if (string.IsNullOrEmpty(expected))

View File

@ -167,6 +167,17 @@ public class SimpleAdminApiModule : FakeModule
// 路由 - 必须在认证和授权之前 // 路由 - 必须在认证和授权之前
app.UseRouting(); app.UseRouting();
// OpenAPI 验签需读取原始 Body,允许二次读流
app.Use(async (ctx, next) =>
{
if (ctx.Request.Path.StartsWithSegments("/openapi"))
{
ctx.Request.EnableBuffering();
}
await next();
});
// 认证和授权 // 认证和授权
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();