using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using Admin.NET.Core;
using Admin.NET.Core.Service;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Admin.NET.Plugin.AiDOP.DataPlatform.Inbound;
///
/// 插件侧入站验签:签名串含原始 body 摘要 + Idempotency-Key。不改框架 Core。
///
public sealed class InboundSignatureHandler : AuthenticationHandler
{
private readonly SysOpenAccessService _openAccess;
private readonly SysCacheService _cache;
public InboundSignatureHandler(
IOptionsMonitor options,
ILoggerFactory logger,
UrlEncoder encoder,
SysOpenAccessService openAccess,
SysCacheService cache)
: base(options, logger, encoder)
{
_openAccess = openAccess;
_cache = cache;
}
protected override async Task HandleAuthenticateAsync()
{
var accessKey = Header(InboundSignatureDefaults.AccessKeyHeader);
var timestampStr = Header(InboundSignatureDefaults.TimestampHeader);
var nonce = Header(InboundSignatureDefaults.NonceHeader);
var signature = Header(InboundSignatureDefaults.SignatureHeader);
var idempotencyKey = Header(InboundSignatureDefaults.IdempotencyKeyHeader);
var contentShaHeader = Header(InboundSignatureDefaults.ContentSha256Header);
if (string.IsNullOrWhiteSpace(idempotencyKey))
return Fail(InboundSignatureDefaults.MissingIdempotencyKey);
if (string.IsNullOrWhiteSpace(accessKey))
return Fail("missing X-Access-Key");
if (string.IsNullOrWhiteSpace(timestampStr))
return Fail("missing X-Timestamp");
if (string.IsNullOrWhiteSpace(nonce))
return Fail("missing X-Nonce");
if (string.IsNullOrWhiteSpace(signature))
return Fail("missing X-Signature");
if (string.IsNullOrWhiteSpace(contentShaHeader))
return Fail("missing X-Content-SHA256");
if (!long.TryParse(timestampStr, out var unixSec))
return Fail("invalid X-Timestamp");
var nowUnix = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (Math.Abs(nowUnix - unixSec) > Options.AllowedDateDrift.TotalSeconds)
return Fail("timestamp outside allowed window");
Request.EnableBuffering();
using var ms = new MemoryStream();
await Request.Body.CopyToAsync(ms);
Request.Body.Position = 0;
var bodyBytes = ms.ToArray();
var contentSha256 = Convert.ToHexString(SHA256.HashData(bodyBytes)).ToLowerInvariant();
if (!string.Equals(contentSha256, contentShaHeader.Trim(), StringComparison.OrdinalIgnoreCase))
return Fail(InboundSignatureDefaults.ContentDigestMismatch);
SysOpenAccess openAccess;
try
{
openAccess = await _openAccess.GetByKey(accessKey);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "InboundSignature GetByKey failed");
return Fail("unknown AccessKey");
}
if (openAccess == null || string.IsNullOrWhiteSpace(openAccess.AccessSecret))
return Fail("unknown AccessKey");
var path = Request.Path.Value ?? string.Empty;
// 接收 POST 与方案 §5.2 一致(POST&path&...);schema/receipts 等 GET 用实际方法名。
var method = (Request.Method ?? "POST").Trim().ToUpperInvariant();
var message = $"{method}&{path}&{accessKey}&{timestampStr}&{nonce}&{contentSha256}&{idempotencyKey}";
var expected = Sign(openAccess.AccessSecret, message);
var expectedBytes = Encoding.UTF8.GetBytes(expected);
var actualBytes = Encoding.UTF8.GetBytes(signature);
if (expectedBytes.Length != actualBytes.Length
|| !CryptographicOperations.FixedTimeEquals(expectedBytes, actualBytes))
return Fail("invalid signature");
var nonceKey = $"AIDOP_INBOUND_NONCE:{accessKey}:{nonce}";
if (_cache.ExistKey(nonceKey))
return Fail("nonce replay");
_cache.Set(nonceKey, 1, Options.AllowedDateDrift * 2);
var identity = new ClaimsIdentity(InboundSignatureDefaults.AuthenticationScheme);
identity.AddClaim(new Claim(ClaimConst.TenantId, openAccess.BindTenantId.ToString()));
identity.AddClaim(new Claim(ClaimConst.UserId, openAccess.BindUserId.ToString()));
identity.AddClaim(new Claim(InboundSignatureDefaults.AccessKeyClaim, accessKey));
var ticket = new AuthenticationTicket(
new ClaimsPrincipal(identity),
InboundSignatureDefaults.AuthenticationScheme);
return AuthenticateResult.Success(ticket);
}
protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
{
var result = await HandleAuthenticateOnceSafeAsync();
var message = result.Failure?.Message
?? Context.Items[InboundSignatureDefaults.FailMessageItemKey] as string
?? "unauthorized";
var status = IsBadRequest(message) ? 400 : 401;
if (Context.Response.HasStarted)
return;
Context.Response.StatusCode = status;
Context.Response.ContentType = "application/json; charset=utf-8";
await Context.Response.WriteAsync($"{{\"code\":{status},\"message\":{System.Text.Json.JsonSerializer.Serialize(message)},\"data\":null}}");
}
private AuthenticateResult Fail(string message)
{
Context.Items[InboundSignatureDefaults.FailMessageItemKey] = message;
return AuthenticateResult.Fail(message);
}
private string Header(string name) => Request.Headers[name].FirstOrDefault();
private static bool IsBadRequest(string message) =>
string.Equals(message, InboundSignatureDefaults.MissingIdempotencyKey, StringComparison.OrdinalIgnoreCase)
|| string.Equals(message, InboundSignatureDefaults.ContentDigestMismatch, StringComparison.OrdinalIgnoreCase);
private static string Sign(string secret, string message)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
return Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(message)));
}
}