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))); } }