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>
|
/// </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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user