admin/src/Oms.Application/Shared/CustomerOpenApiAuthHelper.cs
zzh 1b6e91b09d
All checks were successful
Build and Deploy / deploy (push) Successful in 1m0s
feat: OpenAPI 鉴权改 HMAC 签名校验,并完善下单报文处理
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 17:33:46 +08:00

156 lines
5.3 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Security.Cryptography;
using System.Text;
using Fake.Application;
using Fake.DependencyInjection;
using Microsoft.AspNetCore.Http;
using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.CustomersAggregate.Enums;
using Oms.Domain.CustomersAggregate.Repositories;
namespace Oms.Application.Shared;
/// <summary>
/// 客户 OpenAPI 机机鉴权:appKey + timestamp + HMAC-SHA256(sign),Secret 仅本地参与签名。
/// </summary>
public class CustomerOpenApiAuthHelper : IScopedDependency
{
public const string AppKeyHeaderName = "appKey";
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;
private readonly IHttpContextAccessor _httpContextAccessor;
public CustomerOpenApiAuthHelper(
ICustomerOpenApiCredentialRepository credentialRepository,
ICustomerRepository customerRepository,
IHttpContextAccessor httpContextAccessor)
{
_credentialRepository = credentialRepository;
_customerRepository = customerRepository;
_httpContextAccessor = httpContextAccessor;
}
/// <summary>
/// 校验 Header 签名并解析主客户。
/// stringToSign = appKey + "\n" + timestamp + "\n" + sha256Hex(rawBody)
/// sign = hex(HMAC-SHA256(appSecret, stringToSign))
/// </summary>
public async Task<(Customer Customer, CustomerOpenApiCredential Credential)> ResolveSignedAsync(
string? appKey,
string? timestamp,
string? sign,
string? rawBody,
CancellationToken cancellationToken = default)
{
var key = appKey?.Trim();
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
|| 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);
if (customer == null
|| customer.ParentCustomerId.HasValue
|| customer.Status != CustomerStatus.Normal)
{
throw new BusinessException("凭证无效");
}
credential.LastUsedAt = DateTime.Now;
credential.LastUsedIp = ResolveClientIp();
await _credentialRepository.UpdateAsync(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)
{
if (string.IsNullOrEmpty(expected))
{
return false;
}
var left = Encoding.UTF8.GetBytes(expected);
var right = Encoding.UTF8.GetBytes(actual);
return left.Length == right.Length
&& CryptographicOperations.FixedTimeEquals(left, right);
}
private string? ResolveClientIp()
{
var http = _httpContextAccessor.HttpContext;
if (http == null)
{
return null;
}
var forwarded = http.Request.Headers["X-Forwarded-For"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(forwarded))
{
return forwarded.Split(',')[0].Trim();
}
return http.Connection.RemoteIpAddress?.ToString();
}
}