using System.Security.Cryptography; using System.Text; using System.Text.Json; using MiGu.Server.Configs; using MiGu.Server.Infra; namespace MiGu.Server.Auth; /// /// RBAC 权威存储:用户 + 角色 + 权限页面授权,内存态 + data/rbac.json 文件持久化。 /// /// 取代旧的硬编码 UserStore + AuthController.BuildPermissions: /// - 登录密码校验、角色解析、有效权限(页面 / 操作 / 控件)全部由本类计算; /// - 管理端「权限与角色」页通过 RbacController 增删改用户 / 角色,落盘后即时生效; /// - 密码以 PBKDF2-SHA256(100k, 16B salt) 哈希存储,比较走 FixedTimeEquals 防时序攻击。 /// /// 首次启动(rbac.json 不存在)时 seed 两个内置账号: /// admin(超级管理员,scope=*,全部页面 / 操作) /// ops (运营人员,scope=RCSMonitor,运营四页 + 运维操作码) /// 初始密码取 appsettings Auth:Users:{name}:Password,缺省 admin/ops(开发弱口令,生产须改)。 /// public sealed class RbacStore { public sealed record EffectiveResult(List Pages, List Ops, List Widgets); private const string RoleAdminId = "role-admin"; private const string RoleOpsId = "role-ops"; private readonly object _gate = new(); private readonly string _file; private readonly ILogger _logger; private readonly JsonSerializerOptions _jsonOpts = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = true }; private RbacSnapshot _snapshot = new(); public RbacStore(IConfiguration config, IWebHostEnvironment env, ILogger logger) { _logger = logger; var dataDir = Path.Combine(env.ContentRootPath, "data"); Directory.CreateDirectory(dataDir); _file = Path.Combine(dataDir, "rbac.json"); Load(config); } // ───────────────────────── 加载 / 持久化 ───────────────────────── private void Load(IConfiguration config) { if (File.Exists(_file)) { try { var json = File.ReadAllText(_file); var snap = JsonSerializer.Deserialize(json, _jsonOpts); if (snap is { Users.Count: > 0 }) { _snapshot = Normalize(snap); _logger.LogInformation("RBAC 从 {File} 载入:{Users} 用户 / {Roles} 角色。", _file, _snapshot.Users.Count, _snapshot.Roles.Count); return; } _logger.LogWarning("RBAC 文件 {File} 内容为空或无用户,回退到默认 seed。", _file); } catch (Exception ex) { _logger.LogError(ex, "RBAC 文件 {File} 解析失败,回退到默认 seed。", _file); // S2:先备份疑似损坏的 rbac.json,避免随后默认 seed 的写入把自定义用户 / 角色 // 永久冲掉(损坏 → 静默重置成 admin/ops 弱口令是高危场景)。 var bak = AtomicFile.BackupCorrupt(_file); if (bak != null) _logger.LogWarning("已备份疑似损坏的 RBAC 文件到 {Backup},请人工核查后恢复自定义数据。", bak); } } _snapshot = SeedDefault(config); Persist(); _logger.LogInformation("RBAC 已生成默认数据并写入 {File}(admin / ops)。", _file); } /// 清洗加载结果:补默认、去重、过滤非法页面 Key,保证内置角色存在。 private static RbacSnapshot Normalize(RbacSnapshot snap) { snap.Roles ??= new(); snap.Users ??= new(); foreach (var r in snap.Roles) { r.Pages = (r.Pages ?? new()) .Select(p => p == PageCatalog.Wildcard ? p : PageCatalog.NormalizeKey(p)) .Where(p => p == PageCatalog.Wildcard || PageCatalog.IsValidKey(p)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); r.Ops = (r.Ops ?? new()).Distinct().ToList(); r.WidgetGrants ??= new(); if (string.IsNullOrWhiteSpace(r.Scope)) r.Scope = PageCatalog.ScopePlatform; BackfillKnownPageMigrations(r); } foreach (var u in snap.Users) { u.RoleIds = (u.RoleIds ?? new()).Distinct().ToList(); } return snap; } private static void BackfillKnownPageMigrations(RbacRole r) { if (!string.Equals(r.Scope, PageCatalog.ScopePlatform, StringComparison.OrdinalIgnoreCase) && r.Scope != PageCatalog.Wildcard) return; // 任务编排页(admin-task-templates,原任务模板 / WorkflowEditor)与脚本、进程管理同属一组编排能力。 // 旧的 admin-missions 入口已下线:上面 Normalize 会按 PageCatalog 过滤掉历史角色里的该 key。 // 这里在角色已有进程 + 脚本入口时补齐「任务编排」菜单权限。 var hasProcessAndScript = r.Pages.Contains("admin-processes", StringComparer.OrdinalIgnoreCase) && r.Pages.Contains("admin-scripts", StringComparer.OrdinalIgnoreCase); if (!r.Pages.Contains(PageCatalog.Wildcard) && !r.Pages.Contains("admin-task-templates", StringComparer.OrdinalIgnoreCase) && hasProcessAndScript) r.Pages.Add("admin-task-templates"); } private RbacSnapshot SeedDefault(IConfiguration config) { var adminPwd = config["Auth:Users:admin:Password"] ?? "admin"; var opsPwd = config["Auth:Users:ops:Password"] ?? "ops"; if (adminPwd == "admin" || opsPwd == "ops") _logger.LogWarning("RBAC seed 使用默认弱密码(admin/ops),生产环境请尽快在「权限与角色」页修改或通过环境变量覆盖。"); var snap = new RbacSnapshot { Version = 1, Roles = new List { new() { Id = RoleAdminId, Name = "超级管理员", Description = "拥有全部页面与操作权限的内置角色", Scope = PageCatalog.Wildcard, Pages = new() { PageCatalog.Wildcard }, Ops = new() { "*" }, WidgetGrants = new(), System = true }, new() { Id = RoleOpsId, Name = "运营人员", Description = "运营监控端默认角色:可执行运维操作、查看监控", Scope = PageCatalog.ScopeMonitor, Pages = new() { "monitor-dashboard", "monitor-vehicle-hub", "monitor-map", "monitor-ops", "monitor-notes" }, Ops = new() { "ops.car.pause", "ops.car.resume", "ops.car.gohome", "ops.car.resetSession", "ops.car.manualCharge", "ops.task.pause", "ops.task.cancel", "ops.task.reassign", "ops.task.boostPriority", "monitor.note.write" }, WidgetGrants = new() { new("MapEditor", "readonly"), new("CadToolbar", "hidden"), new("CarPanel", "readonly"), new("MissionEditor", "readonly"), new("OpsActionPanel", "interactive"), new("ConfigCenter", "hidden") }, System = true } }, Users = new List() }; snap.Users.Add(NewUser("u-admin", "admin", "系统管理员", adminPwd, new() { RoleAdminId })); snap.Users.Add(NewUser("u-ops", "ops", "运营人员", opsPwd, new() { RoleOpsId })); return snap; } private void Persist() { try { AtomicFile.WriteAllText(_file, JsonSerializer.Serialize(_snapshot, _jsonOpts)); } catch (Exception ex) { _logger.LogError(ex, "RBAC 持久化到 {File} 失败。", _file); } } // ───────────────────────── 登录 / 鉴权读取 ───────────────────────── /// 用户名 + 密码校验。返回 null = 不存在 / 已禁用 / 密码错(不区分原因,防用户名枚举)。 public RbacUser? VerifyCredentials(string username, string password) { if (string.IsNullOrWhiteSpace(username) || string.IsNullOrEmpty(password)) return null; lock (_gate) { var u = FindByName(username); if (u is null || !u.Enabled) return null; if (!VerifyHash(password, u.Salt, u.PasswordHash)) return null; return Clone(u); } } public RbacUser? FindUser(string username) { lock (_gate) { var u = FindByName(username); return u is null ? null : Clone(u); } } /// 当前用户可登录的 scope 集合(其角色覆盖的 scope,* 角色覆盖全部)。 public List UsableScopes(RbacUser user) { lock (_gate) { var roles = RolesOf(user); var set = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var r in roles) { if (r.Scope == PageCatalog.Wildcard) { set.Add(PageCatalog.ScopePlatform); set.Add(PageCatalog.ScopeMonitor); } else set.Add(r.Scope); } return set.ToList(); } } public bool CanUseScope(RbacUser user, string scope) => UsableScopes(user).Contains(scope, StringComparer.OrdinalIgnoreCase); /// 角色名(展示用,写入 AuthUserDto.Roles / JWT role claim)。 public List RoleNamesOf(RbacUser user) { lock (_gate) { return RolesOf(user).Select(r => r.Name).ToList(); } } /// /// 计算用户在指定 scope 下的有效权限:可访问页面、操作码、控件可见性,均取适用角色的并集。 /// 适用角色 = 角色 scope 等于该 scope,或角色 scope 为通配 *。 /// public EffectiveResult ComputeEffective(RbacUser user, string scope) { lock (_gate) { var roles = RolesOf(user).Where(r => r.Scope == PageCatalog.Wildcard || string.Equals(r.Scope, scope, StringComparison.OrdinalIgnoreCase)).ToList(); var scopeKeys = PageCatalog.KeysForScope(scope).ToHashSet(StringComparer.OrdinalIgnoreCase); var pages = new HashSet(StringComparer.OrdinalIgnoreCase); var ops = new HashSet(StringComparer.Ordinal); var allOps = false; var bestWidget = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var r in roles) { if (r.Pages.Contains(PageCatalog.Wildcard)) pages.UnionWith(scopeKeys); else foreach (var p in r.Pages) if (scopeKeys.Contains(p)) pages.Add(p); foreach (var o in r.Ops) { if (o == "*") allOps = true; else ops.Add(o); } foreach (var g in r.WidgetGrants) { if (!bestWidget.TryGetValue(g.WidgetId, out var cur) || Rank(g.Visibility) > Rank(cur)) bestWidget[g.WidgetId] = g.Visibility; } } return new EffectiveResult( pages.OrderBy(p => p, StringComparer.Ordinal).ToList(), allOps ? new List { "*" } : ops.OrderBy(o => o, StringComparer.Ordinal).ToList(), bestWidget.Select(kv => new WidgetGrantDto(kv.Key, kv.Value)).ToList()); } } // ───────────────────────── 管理端读取 ───────────────────────── public List ListRoles() { lock (_gate) { return _snapshot.Roles.Select(Clone).ToList(); } } public List ListUsers() { lock (_gate) { return _snapshot.Users.Select(ToDto).ToList(); } } // ───────────────────────── 用户 CRUD ───────────────────────── public RbacUserDto CreateUser(CreateUserRequest req) { if (string.IsNullOrWhiteSpace(req.Username)) throw new RbacException("用户名不能为空"); if (string.IsNullOrEmpty(req.Password)) throw new RbacException("初始密码不能为空"); lock (_gate) { if (FindByName(req.Username) is not null) throw new RbacException($"用户名 {req.Username} 已存在"); var roleIds = FilterExistingRoles(req.RoleIds); var user = NewUser($"u-{NewId()}", req.Username.Trim(), string.IsNullOrWhiteSpace(req.DisplayName) ? req.Username.Trim() : req.DisplayName!.Trim(), req.Password, roleIds); user.Enabled = req.Enabled; _snapshot.Users.Add(user); Persist(); return ToDto(user); } } public RbacUserDto UpdateUser(string id, UpdateUserRequest req) { lock (_gate) { var u = _snapshot.Users.FirstOrDefault(x => x.Id == id) ?? throw new RbacException("用户不存在"); var oldName = u.DisplayName; var oldRoles = u.RoleIds; var oldEnabled = u.Enabled; if (req.DisplayName is not null) u.DisplayName = req.DisplayName.Trim(); if (req.RoleIds is not null) u.RoleIds = FilterExistingRoles(req.RoleIds); if (req.Enabled is bool en) u.Enabled = en; // M5:若本次改动(改角色 / 停用)导致系统再无有效管理员,则回滚后报错。 try { EnsureAdminRemainsNoLock(); } catch { u.DisplayName = oldName; u.RoleIds = oldRoles; u.Enabled = oldEnabled; throw; } Persist(); return ToDto(u); } } public void SetPassword(string id, string password) { if (string.IsNullOrEmpty(password)) throw new RbacException("密码不能为空"); lock (_gate) { var u = _snapshot.Users.FirstOrDefault(x => x.Id == id) ?? throw new RbacException("用户不存在"); var salt = RandomNumberGenerator.GetBytes(16); u.Salt = Convert.ToBase64String(salt); u.PasswordHash = Convert.ToBase64String(Pbkdf2(password, salt)); Persist(); } } public void DeleteUser(string id) { lock (_gate) { var u = _snapshot.Users.FirstOrDefault(x => x.Id == id) ?? throw new RbacException("用户不存在"); var idx = _snapshot.Users.IndexOf(u); _snapshot.Users.RemoveAt(idx); // M5:删除后若系统再无有效管理员,则恢复并报错。 try { EnsureAdminRemainsNoLock(); } catch { _snapshot.Users.Insert(idx, u); throw; } Persist(); } } // ───────────────────────── 角色 CRUD ───────────────────────── public RbacRole CreateRole(SaveRoleRequest req) { if (string.IsNullOrWhiteSpace(req.Name)) throw new RbacException("角色名称不能为空"); var scope = NormalizeScope(req.Scope); lock (_gate) { var role = new RbacRole { Id = $"role-{NewId()}", Name = req.Name.Trim(), Description = req.Description?.Trim() ?? "", Scope = scope, Pages = SanitizePages(req.Pages), Ops = req.Ops?.Distinct().ToList() ?? new(), WidgetGrants = req.WidgetGrants ?? new(), System = false }; _snapshot.Roles.Add(role); Persist(); return Clone(role); } } public RbacRole UpdateRole(string id, SaveRoleRequest req) { if (string.IsNullOrWhiteSpace(req.Name)) throw new RbacException("角色名称不能为空"); var scope = NormalizeScope(req.Scope); lock (_gate) { var role = _snapshot.Roles.FirstOrDefault(r => r.Id == id) ?? throw new RbacException("角色不存在"); var backup = Clone(role); role.Name = req.Name.Trim(); role.Description = req.Description?.Trim() ?? ""; role.Scope = scope; role.Pages = SanitizePages(req.Pages); role.Ops = req.Ops?.Distinct().ToList() ?? new(); role.WidgetGrants = req.WidgetGrants ?? new(); // M5:若本次改动(如去掉角色的 "*"/auth.manage)导致系统再无有效管理员,则回滚。 try { EnsureAdminRemainsNoLock(); } catch { role.Name = backup.Name; role.Description = backup.Description; role.Scope = backup.Scope; role.Pages = backup.Pages; role.Ops = backup.Ops; role.WidgetGrants = backup.WidgetGrants; throw; } Persist(); return Clone(role); } } public void DeleteRole(string id) { lock (_gate) { var role = _snapshot.Roles.FirstOrDefault(r => r.Id == id) ?? throw new RbacException("角色不存在"); if (role.System) throw new RbacException("内置系统角色不可删除"); var inUse = _snapshot.Users.Where(u => u.RoleIds.Contains(id)).Select(u => u.Username).ToList(); if (inUse.Count > 0) throw new RbacException($"角色仍被 {inUse.Count} 个用户使用({string.Join(", ", inUse.Take(5))}{(inUse.Count > 5 ? "…" : "")}),请先解除关联"); _snapshot.Roles.Remove(role); Persist(); } } public bool RoleExists(string id) { lock (_gate) { return _snapshot.Roles.Any(r => r.Id == id); } } // ───────────────────────── 内部工具 ───────────────────────── private RbacUser? FindByName(string username) => _snapshot.Users.FirstOrDefault(u => string.Equals(u.Username, username, StringComparison.OrdinalIgnoreCase)); private List RolesOf(RbacUser user) => user.RoleIds.Select(id => _snapshot.Roles.FirstOrDefault(r => r.Id == id)) .Where(r => r is not null).Select(r => r!).ToList(); private const string OpAuthManage = "auth.manage"; /// /// 该用户当前是否为「有效系统管理员」:启用 且 至少一个角色的 Ops 含 "*" 或 "auth.manage"。 /// 用于 M5「最后管理员」保护。 /// private bool IsActiveAdminNoLock(RbacUser u) { if (!u.Enabled) return false; foreach (var r in RolesOf(u)) if (r.Ops.Contains("*") || r.Ops.Contains(OpAuthManage)) return true; return false; } private int CountActiveAdminsNoLock() => _snapshot.Users.Count(IsActiveAdminNoLock); /// 校验修改应用后系统仍至少有一名有效管理员,否则抛异常(调用方负责回滚内存改动)。 private void EnsureAdminRemainsNoLock() { if (CountActiveAdminsNoLock() == 0) throw new RbacException("该操作会使系统再无任何具备管理权限(auth.manage)的启用账号,已阻止。请至少保留一名超级管理员。"); } private List FilterExistingRoles(List? roleIds) => (roleIds ?? new()).Where(id => _snapshot.Roles.Any(r => r.Id == id)).Distinct().ToList(); private static List SanitizePages(List? pages) { if (pages is null) return new(); if (pages.Contains(PageCatalog.Wildcard)) return new() { PageCatalog.Wildcard }; return pages.Where(PageCatalog.IsValidKey).Distinct().ToList(); } private static string NormalizeScope(string? scope) => scope switch { PageCatalog.ScopePlatform => PageCatalog.ScopePlatform, PageCatalog.ScopeMonitor => PageCatalog.ScopeMonitor, PageCatalog.Wildcard => PageCatalog.Wildcard, _ => throw new RbacException($"无效 scope: {scope}(应为 Platform / RCSMonitor / *)") }; private static int Rank(string visibility) => visibility switch { "interactive" => 2, "readonly" => 1, _ => 0 }; private static RbacUser NewUser(string id, string username, string displayName, string password, List roleIds) { var salt = RandomNumberGenerator.GetBytes(16); return new RbacUser { Id = id, Username = username, DisplayName = displayName, Enabled = true, RoleIds = roleIds, Salt = Convert.ToBase64String(salt), PasswordHash = Convert.ToBase64String(Pbkdf2(password, salt)) }; } private RbacUserDto ToDto(RbacUser u) => new(u.Id, u.Username, u.DisplayName, u.Enabled, new List(u.RoleIds), UsableScopesNoLock(u)); private List UsableScopesNoLock(RbacUser user) { var set = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var r in RolesOf(user)) { if (r.Scope == PageCatalog.Wildcard) { set.Add(PageCatalog.ScopePlatform); set.Add(PageCatalog.ScopeMonitor); } else set.Add(r.Scope); } return set.ToList(); } private static bool VerifyHash(string password, string saltB64, string hashB64) { try { var salt = Convert.FromBase64String(saltB64); var expected = Convert.FromBase64String(hashB64); var actual = Pbkdf2(password, salt); return CryptographicOperations.FixedTimeEquals(actual, expected); } catch { return false; } } private static byte[] Pbkdf2(string password, byte[] salt) => Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), salt, iterations: 100_000, HashAlgorithmName.SHA256, 32); private static string NewId() => Guid.NewGuid().ToString("N")[..8]; private static RbacUser Clone(RbacUser u) => new() { Id = u.Id, Username = u.Username, DisplayName = u.DisplayName, Enabled = u.Enabled, RoleIds = new List(u.RoleIds), Salt = u.Salt, PasswordHash = u.PasswordHash }; private static RbacRole Clone(RbacRole r) => new() { Id = r.Id, Name = r.Name, Description = r.Description, Scope = r.Scope, Pages = new List(r.Pages), Ops = new List(r.Ops), WidgetGrants = r.WidgetGrants.Select(w => new WidgetGrantDto(w.WidgetId, w.Visibility)).ToList(), System = r.System }; } /// RBAC 业务校验异常 —— 由 RbacController 统一翻译成 400 + message。 public sealed class RbacException : Exception { public RbacException(string message) : base(message) { } }