using System.Security.Cryptography; using System.Text; using System.Text.Json; using MiGu.Server.Configs; 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); } } _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()).Where(p => p == PageCatalog.Wildcard || PageCatalog.IsValidKey(p)).Distinct().ToList(); r.Ops = (r.Ops ?? new()).Distinct().ToList(); r.WidgetGrants ??= new(); if (string.IsNullOrWhiteSpace(r.Scope)) r.Scope = PageCatalog.ScopePlatform; } foreach (var u in snap.Users) { u.RoleIds = (u.RoleIds ?? new()).Distinct().ToList(); } return snap; } 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-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 { File.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("用户不存在"); 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; 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("用户不存在"); _snapshot.Users.Remove(u); 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("角色不存在"); 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(); 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 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) { } }