feat: OpenAPI 鉴权改 HMAC 签名校验,并完善下单报文处理
All checks were successful
Build and Deploy / deploy (push) Successful in 1m0s
All checks were successful
Build and Deploy / deploy (push) Successful in 1m0s
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
87f4cebc9b
commit
1b6e91b09d
@ -8,9 +8,6 @@ namespace Oms.Application.Dtos.Orders;
|
||||
/// </summary>
|
||||
public class OpenApiParcelCreateInput
|
||||
{
|
||||
/// <summary>请求标识(幂等,可选)。</summary>
|
||||
public string? RequestId { get; set; }
|
||||
|
||||
/// <summary>客户单号。</summary>
|
||||
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,
|
||||
|
||||
@ -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;
|
||||
/// <summary>
|
||||
/// 对外开放 API(Swagger 文档分组:openApi)。
|
||||
/// 统一响应:{ code, msg, data }。
|
||||
/// 客户机机鉴权:请求头 appKey + appSecret(勿放 Body,避免落日志)。
|
||||
/// 客户下单鉴权:Header appKey + timestamp + sign(HMAC-SHA256,5 分钟窗口);Secret 不上传。
|
||||
/// </summary>
|
||||
[ApiExplorerSettings(GroupName = "openApi")]
|
||||
public class OpenApiService : ApplicationService
|
||||
{
|
||||
private static readonly JsonSerializerOptions BodyJsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private CustomerOpenApiAuthHelper AuthHelper =>
|
||||
LazyServiceProvider.GetRequiredService<CustomerOpenApiAuthHelper>();
|
||||
|
||||
@ -25,6 +33,9 @@ public class OpenApiService : ApplicationService
|
||||
private OrderService OrderService =>
|
||||
LazyServiceProvider.GetRequiredService<OrderService>();
|
||||
|
||||
private IHttpContextAccessor HttpContextAccessor =>
|
||||
LazyServiceProvider.GetRequiredService<IHttpContextAccessor>();
|
||||
|
||||
/// <summary>
|
||||
/// 推送揽收件重尺并按当前产品计算应收应付(上游 itfx 鉴权后转发,无需客户凭证)。
|
||||
/// </summary>
|
||||
@ -52,20 +63,27 @@ public class OpenApiService : ApplicationService
|
||||
|
||||
/// <summary>
|
||||
/// 创建订单包裹(仅自发货)。
|
||||
/// 鉴权:Header appKey、appSecret;客户归属强制取自凭证,Body 不可指定 customerId。
|
||||
/// Header:appKey、timestamp(Unix 秒)、sign。
|
||||
/// sign = hex(HMAC-SHA256(appSecret, appKey + "\n" + timestamp + "\n" + sha256Hex(rawBody)))。
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
[HttpPost("/openapi/parcel/create")]
|
||||
public async Task<OpenApiEnvelope<OrderParcelCreateOutput>> 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<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));
|
||||
return OpenApiEnvelope<OrderParcelCreateOutput>.Ok(data);
|
||||
}
|
||||
@ -73,6 +91,10 @@ public class OpenApiService : ApplicationService
|
||||
{
|
||||
return OpenApiEnvelope<OrderParcelCreateOutput>.Fail(ex.Message);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return OpenApiEnvelope<OrderParcelCreateOutput>.Fail("请求体格式错误");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return OpenApiEnvelope<OrderParcelCreateOutput>.Fail(
|
||||
@ -80,4 +102,25 @@ public class OpenApiService : ApplicationService
|
||||
"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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,12 +10,16 @@ using Oms.Domain.CustomersAggregate.Repositories;
|
||||
namespace Oms.Application.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// 客户 OpenAPI 机机鉴权:按请求头 AppKey + AppSecret 解析主客户。
|
||||
/// 客户 OpenAPI 机机鉴权:appKey + timestamp + HMAC-SHA256(sign),Secret 仅本地参与签名。
|
||||
/// </summary>
|
||||
public class CustomerOpenApiAuthHelper : IScopedDependency
|
||||
{
|
||||
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 ICustomerRepository _customerRepository;
|
||||
@ -32,38 +36,52 @@ public class CustomerOpenApiAuthHelper : IScopedDependency
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从当前请求头读取 appKey / appSecret 并鉴权。
|
||||
/// 校验 Header 签名并解析主客户。
|
||||
/// stringToSign = appKey + "\n" + timestamp + "\n" + sha256Hex(rawBody)
|
||||
/// sign = hex(HMAC-SHA256(appSecret, stringToSign))
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
if (string.IsNullOrEmpty(expected))
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user