Files
lserp_cs_6.0/插件库/Lskj.CommandKernel/SecurityServices.cs
2026-08-14 14:28:28 +08:00

516 lines
21 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Lskj.CommandKernel
{
public sealed class InMemoryIdempotencyStore : IIdempotencyStore
{
public const int DefaultMaximumEntries = 4096;
private readonly object _syncRoot = new object();
private readonly Dictionary<string, Entry> _entries =
new Dictionary<string, Entry>(StringComparer.OrdinalIgnoreCase);
private readonly ISystemClock _clock;
private readonly TimeSpan _completedRetention;
private readonly int _maximumEntries;
public InMemoryIdempotencyStore()
: this(new SystemClock(), TimeSpan.FromHours(24), DefaultMaximumEntries)
{
}
public InMemoryIdempotencyStore(
ISystemClock clock,
TimeSpan completedRetention,
int maximumEntries)
{
if (clock == null) throw new ArgumentNullException("clock");
if (completedRetention <= TimeSpan.Zero
|| completedRetention > TimeSpan.FromDays(7))
throw new ArgumentOutOfRangeException(
"completedRetention",
"进程内幂等回放保留期必须在 7 天以内。");
if (maximumEntries < 1 || maximumEntries > 100000)
throw new ArgumentOutOfRangeException(
"maximumEntries",
"进程内幂等记录容量必须在 1-100000 之间。");
_clock = clock;
_completedRetention = completedRetention;
_maximumEntries = maximumEntries;
}
public IdempotencyClaim Claim(
string commandName,
string idempotencyKey,
CommandExecutionContext context,
string inputFingerprint)
{
string key = BuildKey(commandName, idempotencyKey, context);
EnsureFingerprint(inputFingerprint);
lock (_syncRoot)
{
DateTime now = _clock.UtcNow;
PurgeExpiredCompleted(now);
Entry entry;
if (!_entries.TryGetValue(key, out entry))
{
if (_entries.Count >= _maximumEntries)
throw new CommandKernelException(
"idempotency_store_capacity_exceeded",
"进程内幂等回放记录已达到安全上限,请稍后重试或重启受控 ERP 会话。",
6);
_entries.Add(key, new Entry
{
InProgress = true,
InputFingerprint = inputFingerprint,
ExpiresAtUtc = now.Add(_completedRetention)
});
return new IdempotencyClaim { State = IdempotencyClaimState.Acquired };
}
if (!string.Equals(
entry.InputFingerprint, inputFingerprint, StringComparison.OrdinalIgnoreCase))
{
return new IdempotencyClaim { State = IdempotencyClaimState.Conflict };
}
if (entry.InProgress)
return new IdempotencyClaim { State = IdempotencyClaimState.InProgress };
return new IdempotencyClaim
{
State = IdempotencyClaimState.Replay,
Result = Clone(entry.Result)
};
}
}
public void Complete(
string commandName,
string idempotencyKey,
CommandExecutionContext context,
string inputFingerprint,
CommandResult result)
{
if (result == null) throw new ArgumentNullException("result");
string key = BuildKey(commandName, idempotencyKey, context);
EnsureFingerprint(inputFingerprint);
lock (_syncRoot)
{
Entry entry;
if (!_entries.TryGetValue(key, out entry) || !entry.InProgress
|| !string.Equals(
entry.InputFingerprint, inputFingerprint, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("幂等键未被当前执行请求占用。");
entry.InProgress = false;
entry.Result = Clone(result);
entry.ExpiresAtUtc = _clock.UtcNow.Add(_completedRetention);
}
}
public void Abandon(
string commandName,
string idempotencyKey,
CommandExecutionContext context,
string inputFingerprint)
{
string key = BuildKey(commandName, idempotencyKey, context);
EnsureFingerprint(inputFingerprint);
lock (_syncRoot)
{
Entry entry;
if (_entries.TryGetValue(key, out entry) && entry.InProgress
&& string.Equals(
entry.InputFingerprint, inputFingerprint, StringComparison.OrdinalIgnoreCase))
_entries.Remove(key);
}
}
private static string BuildKey(
string commandName,
string idempotencyKey,
CommandExecutionContext context)
{
if (string.IsNullOrWhiteSpace(commandName))
throw new ArgumentException("命令名称不能为空。", "commandName");
if (string.IsNullOrWhiteSpace(idempotencyKey))
throw new ArgumentException("幂等键不能为空。", "idempotencyKey");
if (context == null) throw new ArgumentNullException("context");
if (string.IsNullOrWhiteSpace(context.AccountBook)
|| string.IsNullOrWhiteSpace(context.SubSystemId)
|| string.IsNullOrWhiteSpace(context.UserId)
|| string.IsNullOrWhiteSpace(context.UserName))
{
throw new ArgumentException("幂等存储需要完整的账套、子系统和用户身份作用域。", "context");
}
string separator = "\u001f";
return context.AccountBook.Trim() + separator
+ context.SubSystemId.Trim() + separator
+ context.UserId.Trim() + separator
+ context.UserName.Trim() + separator
+ (context.DatabaseScopeFingerprint ?? string.Empty).Trim()
+ separator
+ commandName.Trim() + separator
+ idempotencyKey.Trim();
}
private static CommandResult Clone(CommandResult source)
{
if (source == null) return null;
CommandResult clone = new CommandResult
{
Success = source.Success,
Code = source.Code,
Message = source.Message,
RecordId = source.RecordId,
Replayed = source.Replayed,
TransactionEvidenceId = source.TransactionEvidenceId,
BusinessAuditId = source.BusinessAuditId
};
foreach (KeyValuePair<string, object> item in source.Data)
clone.Data[item.Key] = item.Value;
return clone;
}
private sealed class Entry
{
public bool InProgress;
public string InputFingerprint;
public CommandResult Result;
public DateTime ExpiresAtUtc;
}
private void PurgeExpiredCompleted(DateTime utcNow)
{
List<string> expired = new List<string>();
foreach (KeyValuePair<string, Entry> item in _entries)
{
if (item.Value != null
&& !item.Value.InProgress
&& item.Value.ExpiresAtUtc <= utcNow)
expired.Add(item.Key);
}
foreach (string key in expired) _entries.Remove(key);
}
private static void EnsureFingerprint(string inputFingerprint)
{
if (!CommandInputFingerprint.IsValid(inputFingerprint))
throw new ArgumentException("输入指纹格式无效。", "inputFingerprint");
}
}
public static class CommandInputFingerprint
{
public static string Create(
string commandName,
IDictionary<string, object> input)
{
if (string.IsNullOrWhiteSpace(commandName))
throw new ArgumentException("命令名称不能为空。", "commandName");
JToken source;
try
{
source = JToken.FromObject(input ?? new Dictionary<string, object>());
// resolutionProof 是服务器短期授权凭证,不是业务事实。采购/请假
// 每次重新 resolve 都会生成新 nonce;若把它计入幂等指纹,网络
// 丢包后以同一业务键重新走完整工作流会被误判为内容冲突。
// 只允许两个固定 create 命令排除顶层精确字段,其他命令、大小写
// 变体和嵌套同名字段仍参与指纹,不能扩大为通用忽略规则。
JObject objectSource = source as JObject;
if (objectSource != null
&& IsResolvedCreateCommand(commandName))
objectSource.Remove("resolutionProof");
}
catch
{
throw new CommandKernelException(
"invalid_input",
"命令输入无法转换为稳定 JSON。",
2);
}
string canonical = Normalize(source).ToString(Formatting.None);
using (SHA256 sha = SHA256.Create())
{
byte[] bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(
commandName.Trim().ToLowerInvariant() + "\u001f" + canonical));
StringBuilder result = new StringBuilder(bytes.Length * 2);
foreach (byte item in bytes) result.Append(item.ToString("x2", CultureInfo.InvariantCulture));
return result.ToString();
}
}
public static bool IsValid(string value)
{
if (string.IsNullOrWhiteSpace(value) || value.Length != 64) return false;
return value.All(item => (item >= '0' && item <= '9')
|| (item >= 'a' && item <= 'f')
|| (item >= 'A' && item <= 'F'));
}
private static bool IsResolvedCreateCommand(string commandName)
{
string normalized = (commandName ?? string.Empty).Trim();
return string.Equals(
normalized,
"purchase.invoice.create",
StringComparison.OrdinalIgnoreCase)
|| string.Equals(
normalized,
"hr.leave.create",
StringComparison.OrdinalIgnoreCase);
}
private static JToken Normalize(JToken value)
{
JObject objectValue = value as JObject;
if (objectValue != null)
{
JObject normalized = new JObject();
foreach (JProperty property in objectValue.Properties()
.OrderBy(item => item.Name, StringComparer.Ordinal))
{
normalized.Add(property.Name, Normalize(property.Value));
}
return normalized;
}
JArray arrayValue = value as JArray;
if (arrayValue != null)
return new JArray(arrayValue.Select(Normalize));
return value == null ? JValue.CreateNull() : value.DeepClone();
}
}
/// <summary>
/// 为实际 ERP 数据库生成不透明、无凭据的稳定作用域指纹。这里只接收
/// 受信任登录运行时读取的值,命令输入和模型均不能指定这些参数。
/// </summary>
public static class ErpDatabaseScopeFingerprint
{
public static string Create(
string provider,
string server,
string database)
{
return CommandInputFingerprint.Create(
"erp.database-scope",
new Dictionary<string, object>
{
{ "provider", Normalize(provider, "数据库提供者") },
{ "server", Normalize(server, "数据库服务器") },
{ "database", Normalize(database, "数据库名称") }
});
}
/// <summary>
/// 同时绑定 ERP 配置端点和当前 DbConnection 实际报告的端点。同名
/// 数据库被切换到另一台服务器、连接被替换,或登录后配置被改动时,
/// 指纹都会改变,旧计划、验收证据和幂等作用域不能继续复用。
/// 配置库名与实际库名必须一致;服务器允许使用别名或规范化后的不同
/// 文本,但两者都会进入私有指纹,不能由命令输入或模型指定。
/// </summary>
public static string CreateBound(
string provider,
string configuredServer,
string configuredDatabase,
string connectedServer,
string connectedDatabase)
{
string normalizedConfiguredDatabase = Normalize(
configuredDatabase,
"配置数据库名称");
string normalizedConnectedDatabase = Normalize(
connectedDatabase,
"实际数据库名称");
if (!string.Equals(
normalizedConfiguredDatabase,
normalizedConnectedDatabase,
StringComparison.Ordinal))
{
throw new CommandKernelException(
"erp_database_session_changed",
"当前 ERP 配置与实际数据库连接不一致,请重新登录后再执行命令。",
6);
}
return CommandInputFingerprint.Create(
"erp.database-scope.v2",
new Dictionary<string, object>
{
{ "provider", Normalize(provider, "数据库提供者") },
{ "configuredServer", Normalize(
configuredServer,
"配置数据库服务器") },
{ "connectedServer", Normalize(
connectedServer,
"实际数据库服务器") },
{ "database", normalizedConnectedDatabase }
});
}
private static string Normalize(string value, string label)
{
value = (value ?? string.Empty).Trim();
if (value.Length == 0 || value.Length > 512
|| value.Any(char.IsControl))
{
throw new CommandKernelException(
"erp_database_scope_invalid",
label + "无效,不能建立可信 ERP 数据库作用域。",
6);
}
return value.ToLowerInvariant();
}
}
public sealed class HmacConfirmationService : IConfirmationValidator, IConfirmationTokenIssuer
{
private readonly byte[] _secret;
private readonly ISystemClock _clock;
public HmacConfirmationService(byte[] secret, ISystemClock clock)
{
if (secret == null || secret.Length < 32)
throw new ArgumentException("确认令牌密钥至少需要 32 字节。", "secret");
if (clock == null) throw new ArgumentNullException("clock");
_secret = (byte[])secret.Clone();
_clock = clock;
}
public string Issue(CommandPlan plan, CommandExecutionContext context, TimeSpan lifetime)
{
if (plan == null) throw new ArgumentNullException("plan");
if (context == null) throw new ArgumentNullException("context");
if (!string.Equals(
plan.CorrelationId ?? string.Empty,
context.CorrelationId ?? string.Empty,
StringComparison.Ordinal))
{
throw new ArgumentException(
"确认令牌只能签发给计划原始关联 ID。",
"context");
}
if (lifetime <= TimeSpan.Zero || lifetime > TimeSpan.FromMinutes(5))
throw new ArgumentOutOfRangeException("lifetime", "确认令牌有效期必须在 5 分钟以内。");
DateTime expiresAt = _clock.UtcNow.Add(lifetime);
if (expiresAt > plan.ExpiresAtUtc) expiresAt = plan.ExpiresAtUtc;
long ticks = expiresAt.ToUniversalTime().Ticks;
string payload = Canonical(plan, context, ticks);
return ticks.ToString(CultureInfo.InvariantCulture) + "." + Sign(payload);
}
public bool Validate(
CommandPlan plan,
CommandExecutionContext context,
out string failureReason)
{
failureReason = null;
if (plan == null || context == null || string.IsNullOrWhiteSpace(context.ConfirmationToken))
{
failureReason = "该操作需要在 ERP 原生确认窗口中确认。";
return false;
}
if (!string.Equals(
plan.CorrelationId ?? string.Empty,
context.CorrelationId ?? string.Empty,
StringComparison.Ordinal))
{
failureReason = "确认令牌与当前关联 ID 不匹配。";
return false;
}
string[] parts = context.ConfirmationToken.Split('.');
long ticks;
if (parts.Length != 2
|| !long.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out ticks))
{
failureReason = "确认令牌格式无效。";
return false;
}
DateTime expiresAt;
try
{
expiresAt = new DateTime(ticks, DateTimeKind.Utc);
}
catch (ArgumentOutOfRangeException)
{
failureReason = "确认令牌时间无效。";
return false;
}
if (_clock.UtcNow > expiresAt || expiresAt > plan.ExpiresAtUtc)
{
failureReason = "确认已过期,请重新查看预览并确认。";
return false;
}
string expected = Sign(Canonical(plan, context, ticks));
if (!FixedTimeEquals(expected, parts[1]))
{
failureReason = "确认令牌与当前用户、账套或计划不匹配。";
return false;
}
return true;
}
private string Sign(string payload)
{
using (HMACSHA256 hmac = new HMACSHA256(_secret))
{
return Base64Url(hmac.ComputeHash(Encoding.UTF8.GetBytes(payload)));
}
}
private static string Canonical(
CommandPlan plan,
CommandExecutionContext context,
long expiresAtTicks)
{
string separator = "\u001f";
return expiresAtTicks.ToString(CultureInfo.InvariantCulture) + separator
+ Safe(plan.PlanId) + separator
+ Safe(plan.CommandName) + separator
+ Safe(plan.CommandVersion) + separator
+ Safe(plan.InputFingerprint) + separator
+ Safe(plan.CorrelationId) + separator
+ Safe(context.CorrelationId) + separator
+ Safe(context.UserId) + separator
+ Safe(context.UserName) + separator
+ Safe(context.AccountBook) + separator
+ Safe(context.SubSystemId) + separator
+ Safe(context.DatabaseScopeFingerprint) + separator
+ Safe(context.IdempotencyKey) + separator
+ Safe(context.ClientSessionId);
}
private static string Safe(string value)
{
return value ?? string.Empty;
}
private static string Base64Url(byte[] value)
{
return Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
private static bool FixedTimeEquals(string left, string right)
{
byte[] leftBytes = Encoding.ASCII.GetBytes(left ?? string.Empty);
byte[] rightBytes = Encoding.ASCII.GetBytes(right ?? string.Empty);
int difference = leftBytes.Length ^ rightBytes.Length;
int maximum = Math.Max(leftBytes.Length, rightBytes.Length);
for (int index = 0; index < maximum; index++)
{
byte leftByte = index < leftBytes.Length ? leftBytes[index] : (byte)0;
byte rightByte = index < rightBytes.Length ? rightBytes[index] : (byte)0;
difference |= leftByte ^ rightByte;
}
return difference == 0;
}
}
}