All checks were successful
Build and Deploy / deploy (push) Successful in 1m0s
Co-authored-by: Cursor <cursoragent@cursor.com>
156 lines
5.3 KiB
C#
156 lines
5.3 KiB
C#
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();
|
||
}
|
||
}
|