using System.Globalization; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; namespace Lskj.AgentPet.Host.Core.Configuration; public sealed class ErpSessionScopeBinding { private const string TokenDomain = "lserp-pet-session-scope-v3\n"; private static readonly Regex DatabaseFingerprint = new( "^[a-f0-9]{64}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); private static readonly Regex ScopeToken = new( "^[a-f0-9]{32}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); private static readonly ISet ContextFields = new HashSet( new[] { "userId", "userName", "accountBook", "subSystemId", "databaseScopeFingerprint", "subSystemName", "isAdministrator", "activeModule", "openModuleCount", "openModulesTruncated", "openModules" }, StringComparer.Ordinal); private static readonly ISet ModuleFields = new HashSet( new[] { "moduleCode", "navigationCode", "moduleName" }, StringComparer.Ordinal); private ErpSessionScopeBinding( string databaseScopeFingerprint, string userId, string userName, string accountBook, string subSystemId, bool isAdministrator, string token) { DatabaseScopeFingerprint = databaseScopeFingerprint; UserId = userId; UserName = userName; AccountBook = accountBook; SubSystemId = subSystemId; IsAdministrator = isAdministrator; Token = token; } public string DatabaseScopeFingerprint { get; } public string UserId { get; } public string UserName { get; } public string AccountBook { get; } public string SubSystemId { get; } public bool IsAdministrator { get; } public string Token { get; } public static ErpSessionScopeBinding Create( string databaseScopeFingerprint, string userId, string userName, string accountBook, string subSystemId, bool isAdministrator) { string database = ValidateDatabaseFingerprint(databaseScopeFingerprint); string user = ValidateScopeText(userId, "expected_erp_user_invalid"); string name = ValidateScopeText(userName, "expected_erp_user_name_invalid"); string account = ValidateScopeText(accountBook, "expected_erp_account_book_invalid"); string subsystem = ValidateScopeText(subSystemId, "expected_erp_subsystem_invalid"); return new ErpSessionScopeBinding( database, user, name, account, subsystem, isAdministrator, ComputeToken( database, user, name, account, subsystem, isAdministrator)); } public static string ValidateToken(string value) { if (string.IsNullOrWhiteSpace(value) || !string.Equals(value, value.Trim(), StringComparison.Ordinal) || !ScopeToken.IsMatch(value)) { throw new HostError( "bridge_session_scope_token_invalid", "ERP 会话作用域令牌格式无效。"); } return value; } public static bool TokenEquals(string left, string right) { string leftValue = left ?? string.Empty; string rightValue = right ?? string.Empty; if (!ScopeToken.IsMatch(leftValue) || !ScopeToken.IsMatch(rightValue)) return false; return CryptographicOperations.FixedTimeEquals( Encoding.ASCII.GetBytes(leftValue), Encoding.ASCII.GetBytes(rightValue)); } public ErpSessionScopeSnapshot VerifyContextResponse(JsonElement response) { if (response.ValueKind != JsonValueKind.Object || !response.TryGetProperty("success", out JsonElement success) || success.ValueKind != JsonValueKind.True || !response.TryGetProperty("code", out JsonElement code) || code.ValueKind != JsonValueKind.String || !string.Equals(code.GetString(), "ok", StringComparison.Ordinal) || !response.TryGetProperty("data", out JsonElement data)) { throw ProtocolError(); } EnsureExactProperties(data, ContextFields, "ERP 上下文"); string user = ContextText(data, "userId", 256); string userName = ContextText(data, "userName", 500); string account = ContextText(data, "accountBook", 256); string subsystem = ContextText(data, "subSystemId", 256); string database = ContextText(data, "databaseScopeFingerprint", 64); if (!DatabaseFingerprint.IsMatch(database)) throw ProtocolError(); string subSystemName = ContextText(data, "subSystemName", 500); if (!data.TryGetProperty("isAdministrator", out JsonElement administrator) || (administrator.ValueKind != JsonValueKind.True && administrator.ValueKind != JsonValueKind.False)) throw ProtocolError(); string uiStateFingerprint = ProjectModuleState(data, subSystemName); ErpSessionScopeSnapshot snapshot = new( database, user, userName, account, subsystem, administrator.GetBoolean(), uiStateFingerprint); if (!string.Equals(DatabaseScopeFingerprint, snapshot.DatabaseScopeFingerprint, StringComparison.Ordinal) || !string.Equals(UserId, snapshot.UserId, StringComparison.Ordinal) || !string.Equals(UserName, snapshot.UserName, StringComparison.Ordinal) || !string.Equals(AccountBook, snapshot.AccountBook, StringComparison.Ordinal) || !string.Equals(SubSystemId, snapshot.SubSystemId, StringComparison.Ordinal) || IsAdministrator != snapshot.IsAdministrator || !TokenEquals(Token, ComputeToken( snapshot.DatabaseScopeFingerprint, snapshot.UserId, snapshot.UserName, snapshot.AccountBook, snapshot.SubSystemId, snapshot.IsAdministrator))) { throw new HostError( "erp_session_scope_mismatch", "当前 ERP 用户、权限、账套、子系统或数据库已不属于本次批准的桌宠会话。"); } return snapshot; } private static string ComputeToken( string databaseScopeFingerprint, string userId, string userName, string accountBook, string subSystemId, bool isAdministrator) { StringBuilder canonical = new(TokenDomain); AppendPart(canonical, "databaseScopeFingerprint", databaseScopeFingerprint); AppendPart(canonical, "userId", userId); AppendPart(canonical, "userName", userName); AppendPart(canonical, "accountBook", accountBook); AppendPart(canonical, "subSystemId", subSystemId); AppendPart( canonical, "isAdministrator", isAdministrator ? "true" : "false"); byte[] digest = SHA256.HashData(Encoding.UTF8.GetBytes(canonical.ToString())); return Convert.ToHexString(digest.AsSpan(0, 16)).ToLowerInvariant(); } private static void AppendPart(StringBuilder target, string name, string value) { target.Append(name) .Append('=') .Append(Encoding.UTF8.GetByteCount(value).ToString(CultureInfo.InvariantCulture)) .Append(':') .Append(value) .Append('\n'); } private static string ValidateDatabaseFingerprint(string value) { string normalized = (value ?? string.Empty).ToLowerInvariant(); if (!string.Equals(value, value?.Trim(), StringComparison.Ordinal) || !DatabaseFingerprint.IsMatch(normalized)) { throw new HostError( "expected_database_scope_invalid", "预期数据库作用域指纹格式无效。"); } return normalized; } private static string ValidateScopeText(string value, string code) { if (string.IsNullOrWhiteSpace(value) || value.Length > 256 || !string.Equals(value, value.Trim(), StringComparison.Ordinal) || value.Any(char.IsControl)) { throw new HostError(code, "预期 ERP 会话字段格式无效。"); } return value; } private static string ContextText(JsonElement source, string name, int maximumLength) { if (!source.TryGetProperty(name, out JsonElement value) || value.ValueKind != JsonValueKind.String) throw ProtocolError(); string result = value.GetString() ?? string.Empty; if (string.IsNullOrWhiteSpace(result) || result.Length > maximumLength || !string.Equals(result, result.Trim(), StringComparison.Ordinal) || result.Any(char.IsControl)) throw ProtocolError(); return result; } private static string ProjectModuleState( JsonElement data, string subSystemName) { if (!data.TryGetProperty("activeModule", out JsonElement active) || !data.TryGetProperty("openModuleCount", out JsonElement countElement) || countElement.ValueKind != JsonValueKind.Number || !countElement.TryGetInt32(out int count) || count < 0 || !data.TryGetProperty("openModulesTruncated", out JsonElement truncatedElement) || (truncatedElement.ValueKind != JsonValueKind.True && truncatedElement.ValueKind != JsonValueKind.False) || !data.TryGetProperty("openModules", out JsonElement modules) || modules.ValueKind != JsonValueKind.Array || modules.GetArrayLength() > 50 || count < modules.GetArrayLength() || truncatedElement.GetBoolean() != (count > modules.GetArrayLength())) throw ProtocolError(); if (active.ValueKind != JsonValueKind.Null) ValidateModule(active); foreach (JsonElement module in modules.EnumerateArray()) ValidateModule(module); StringBuilder canonical = new(); AppendCanonical(canonical, subSystemName); AppendModule(canonical, active); canonical.Append(count.ToString(CultureInfo.InvariantCulture)) .Append(':') .Append(truncatedElement.GetBoolean() ? '1' : '0') .Append('|'); foreach (JsonElement module in modules.EnumerateArray()) AppendModule(canonical, module); return canonical.ToString(); } private static void AppendModule(StringBuilder target, JsonElement module) { if (module.ValueKind == JsonValueKind.Null) { target.Append("null|"); return; } AppendCanonical(target, ContextText(module, "moduleCode", 128)); AppendCanonical(target, ContextText(module, "navigationCode", 128)); AppendCanonical(target, ContextText(module, "moduleName", 500)); } private static void AppendCanonical(StringBuilder target, string value) { target.Append(value.Length.ToString(CultureInfo.InvariantCulture)) .Append(':') .Append(value) .Append('|'); } private static void ValidateModule(JsonElement module) { EnsureExactProperties(module, ModuleFields, "ERP 模块上下文"); ContextText(module, "moduleCode", 128); ContextText(module, "navigationCode", 128); ContextText(module, "moduleName", 500); } private static void EnsureExactProperties( JsonElement source, ISet expected, string label) { if (source.ValueKind != JsonValueKind.Object) throw ProtocolError(); HashSet found = new(StringComparer.Ordinal); foreach (JsonProperty property in source.EnumerateObject()) { if (!found.Add(property.Name) || !expected.Contains(property.Name)) throw new HostError("bridge_protocol_error", label + "字段无效。"); } if (!found.SetEquals(expected)) throw ProtocolError(); } private static HostError ProtocolError() => new( "bridge_protocol_error", "ERP 桥返回了无效的会话上下文。"); } public sealed class ErpSessionScopeSnapshot : IEquatable { public ErpSessionScopeSnapshot( string databaseScopeFingerprint, string userId, string userName, string accountBook, string subSystemId) : this( databaseScopeFingerprint, userId, userName, accountBook, subSystemId, false, string.Empty) { } internal ErpSessionScopeSnapshot( string databaseScopeFingerprint, string userId, string userName, string accountBook, string subSystemId, bool isAdministrator, string uiStateFingerprint) { DatabaseScopeFingerprint = databaseScopeFingerprint; UserId = userId; UserName = userName; AccountBook = accountBook; SubSystemId = subSystemId; IsAdministrator = isAdministrator; UiStateFingerprint = uiStateFingerprint ?? string.Empty; } public string DatabaseScopeFingerprint { get; } public string UserId { get; } public string UserName { get; } public string AccountBook { get; } public string SubSystemId { get; } public bool IsAdministrator { get; } internal string UiStateFingerprint { get; } internal bool SameSessionIdentity(ErpSessionScopeSnapshot? other) => other is not null && string.Equals(DatabaseScopeFingerprint, other.DatabaseScopeFingerprint, StringComparison.Ordinal) && string.Equals(UserId, other.UserId, StringComparison.Ordinal) && string.Equals(UserName, other.UserName, StringComparison.Ordinal) && string.Equals(AccountBook, other.AccountBook, StringComparison.Ordinal) && string.Equals(SubSystemId, other.SubSystemId, StringComparison.Ordinal) && IsAdministrator == other.IsAdministrator; internal bool SameUiState(ErpSessionScopeSnapshot? other) => other is not null && string.Equals(UiStateFingerprint, other.UiStateFingerprint, StringComparison.Ordinal); public bool Equals(ErpSessionScopeSnapshot? other) { return SameSessionIdentity(other) && SameUiState(other); } public override bool Equals(object? obj) => Equals(obj as ErpSessionScopeSnapshot); public override int GetHashCode() => HashCode.Combine( DatabaseScopeFingerprint, UserId, UserName, AccountBook, SubSystemId, IsAdministrator, UiStateFingerprint); }