391 lines
15 KiB
C#
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);
|
|
}
|