Files
lserp_cs_6.0/插件库/Lskj.AgentPet.Host.Core/Configuration/ErpSessionScopeBinding.cs
T
2026-08-14 14:28:28 +08:00

391 lines
15 KiB
C#

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<string> ContextFields = new HashSet<string>(
new[]
{
"userId", "userName", "accountBook", "subSystemId",
"databaseScopeFingerprint", "subSystemName", "isAdministrator",
"activeModule", "openModuleCount", "openModulesTruncated", "openModules"
},
StringComparer.Ordinal);
private static readonly ISet<string> ModuleFields = new HashSet<string>(
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<string> expected,
string label)
{
if (source.ValueKind != JsonValueKind.Object) throw ProtocolError();
HashSet<string> 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<ErpSessionScopeSnapshot>
{
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);
}