From a5dd632898cc206cab7637464d3fe0f757bf3aa2 Mon Sep 17 00:00:00 2001 From: "zhaowei.huang" <228127304@qq.com> Date: Fri, 29 May 2026 23:51:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(rbac):=20RbacStore=20=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96=E6=9D=83=E9=99=90=E4=BD=93=E7=B3=BB=E6=9B=BF=E4=BB=A3?= =?UTF-8?q?=E7=A1=AC=E7=BC=96=E7=A0=81=20UserStore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 PageCatalog / RbacModels / RbacStore / RbacController:用户、角色、页面/操作/控件授权落盘 data/rbac.json,支持运行时增删改并即时生效 - 密码改用 PBKDF2-SHA256(100k 迭代 + 16B 随机盐) 存储,校验走 FixedTimeEquals 防时序攻击;对外 DTO 绝不外泄盐/哈希 - AuthController 登录 / me / switch-scope 统一收敛到 BuildSession,按角色在当前 scope 的并集计算有效权限并签发 JWT - EffectivePermissions 增加 AllowedPages;移除旧的硬编码 UserStore - Program.cs 注册 RbacStore、新增 RbacAdmin 授权策略(ops claim 含 * 或 auth.manage),并按 SimpleLite:FollowParent 决定是否注册停机清理钩子 --- MiGu.Server/Auth/PageCatalog.cs | 79 ++++ MiGu.Server/Auth/RbacModels.cs | 82 ++++ MiGu.Server/Auth/RbacStore.cs | 481 ++++++++++++++++++++ MiGu.Server/Auth/UserStore.cs | 75 --- MiGu.Server/Configs/EffectivePermissions.cs | 3 +- MiGu.Server/Controllers/AuthController.cs | 237 +++------- MiGu.Server/Controllers/RbacController.cs | 137 ++++++ MiGu.Server/Program.cs | 37 +- 8 files changed, 882 insertions(+), 249 deletions(-) create mode 100644 MiGu.Server/Auth/PageCatalog.cs create mode 100644 MiGu.Server/Auth/RbacModels.cs create mode 100644 MiGu.Server/Auth/RbacStore.cs delete mode 100644 MiGu.Server/Auth/UserStore.cs create mode 100644 MiGu.Server/Controllers/RbacController.cs diff --git a/MiGu.Server/Auth/PageCatalog.cs b/MiGu.Server/Auth/PageCatalog.cs new file mode 100644 index 0000000..248329c --- /dev/null +++ b/MiGu.Server/Auth/PageCatalog.cs @@ -0,0 +1,79 @@ +namespace MiGu.Server.Auth; + +/// +/// 单个「权限页面」定义。Key 与前端 vue-router 的 route.name 一一对齐, +/// 角色通过勾选 Key 集合决定可访问的页面(菜单 + 路由守卫据此放行)。 +/// +public sealed record PageDef(string Key, string Label, string Group, string Scope); + +/// +/// 平台「权限页面清单」——RBAC 的最小授权单元。 +/// +/// 设计:页面是由前端路由静态决定的(相对稳定),因此后端维护一份与 +/// frontends/.../router/index.ts 对齐的静态清单,通过 GET /api/rbac/pages +/// 暴露给「权限与角色」管理页,让管理员可视化地把页面分配给角色。 +/// +/// Scope 含义: +/// - Platform:管理端(/admin/*)页面; +/// - RCSMonitor:运营监控端(/monitor/*)页面。 +/// +public static class PageCatalog +{ + public const string ScopePlatform = "Platform"; + public const string ScopeMonitor = "RCSMonitor"; + + /// 权限页面通配符:角色 Pages 含此值表示「该 scope 下全部页面」(超级管理员)。 + public const string Wildcard = "*"; + + public static readonly IReadOnlyList All = new List + { + // ── 管理端 / Platform:概览 ── + new("admin-dashboard", "总览", "概览", ScopePlatform), + new("admin-map-monitor", "地图监控", "概览", ScopePlatform), + new("admin-playback", "调度回放", "概览", ScopePlatform), + + // ── 管理端 / Platform:设计与编排 ── + new("admin-map-editor", "地图编辑", "设计与编排", ScopePlatform), + new("admin-project-properties", "项目属性", "设计与编排", ScopePlatform), + new("admin-tracks", "场景管理", "设计与编排", ScopePlatform), + new("admin-cars", "车辆管理", "设计与编排", ScopePlatform), + new("admin-processes", "进程管理", "设计与编排", ScopePlatform), + new("admin-scripts", "脚本管理", "设计与编排", ScopePlatform), + new("admin-missions", "任务编排", "设计与编排", ScopePlatform), + + // ── 管理端 / Platform:平台配置中心 ── + new("admin-config-system", "系统级配置", "平台配置中心", ScopePlatform), + new("admin-config-integrations", "外部系统对接", "平台配置中心", ScopePlatform), + new("admin-config-routing", "路径规划", "平台配置中心", ScopePlatform), + new("admin-config-vehicle", "车辆维护", "平台配置中心", ScopePlatform), + new("admin-config-charge", "充电策略", "平台配置中心", ScopePlatform), + new("admin-config-task", "任务分配", "平台配置中心", ScopePlatform), + new("admin-config-traffic", "交通管制", "平台配置中心", ScopePlatform), + new("admin-config-auth", "权限与角色", "平台配置中心", ScopePlatform), + new("admin-config-device", "设备接入", "平台配置中心", ScopePlatform), + new("admin-config-fleet", "车队生命周期", "平台配置中心", ScopePlatform), + new("admin-config-scenario", "场景模板", "平台配置中心", ScopePlatform), + new("admin-config-location", "库位管理", "平台配置中心", ScopePlatform), + new("admin-config-ops", "运营维护", "平台配置中心", ScopePlatform), + new("admin-config-widget", "自定义控件", "平台配置中心", ScopePlatform), + new("admin-config-map-monitor", "地图监控配置", "平台配置中心", ScopePlatform), + + // ── 运营端 / RCSMonitor ── + new("monitor-dashboard", "运营总览", "运营监控", ScopeMonitor), + new("monitor-map", "地图监控", "运营监控", ScopeMonitor), + new("monitor-ops", "运维操作", "运营监控", ScopeMonitor), + new("monitor-notes", "运营备注", "运营监控", ScopeMonitor), + }; + + private static readonly HashSet _keys = + All.Select(p => p.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); + + /// 判断页面 Key 是否合法(用于角色保存时过滤掉脏数据 / 已下线页面)。 + public static bool IsValidKey(string key) => _keys.Contains(key); + + /// 列出某 scope 下的全部页面 Key(用于把角色的 "*" 通配展开成具体页面集合)。 + public static IReadOnlyList KeysForScope(string scope) => + All.Where(p => string.Equals(p.Scope, scope, StringComparison.OrdinalIgnoreCase)) + .Select(p => p.Key) + .ToList(); +} diff --git a/MiGu.Server/Auth/RbacModels.cs b/MiGu.Server/Auth/RbacModels.cs new file mode 100644 index 0000000..5531e15 --- /dev/null +++ b/MiGu.Server/Auth/RbacModels.cs @@ -0,0 +1,82 @@ +using MiGu.Server.Configs; + +namespace MiGu.Server.Auth; + +/// +/// RBAC 角色。一个角色 = 一组「页面 + 操作码 + 控件可见性」授权,归属某个 scope。 +/// +/// Platform / RCSMonitor / *(通用,对两个 scope 都生效)。 +/// :可访问页面 Key 集合(见 );含 * 表示该 scope 全部页面。 +/// :细粒度操作码(如 ops.car.pause);含 * 表示全部操作。 +/// :控件级可见性(hidden / readonly / interactive)。 +/// :内置系统角色,禁止删除(可改名/调权限但保底不被误删)。 +/// +/// +public sealed class RbacRole +{ + public string Id { get; set; } = ""; + public string Name { get; set; } = ""; + public string Description { get; set; } = ""; + public string Scope { get; set; } = PageCatalog.ScopePlatform; + public List Pages { get; set; } = new(); + public List Ops { get; set; } = new(); + public List WidgetGrants { get; set; } = new(); + public bool System { get; set; } +} + +/// +/// RBAC 用户。密码以 PBKDF2-SHA256 哈希存储( / 均为 base64)。 +/// 一个用户可拥有多个角色,其有效权限 = 当前 scope 下各角色授权的并集。 +/// +public sealed class RbacUser +{ + public string Id { get; set; } = ""; + public string Username { get; set; } = ""; + public string DisplayName { get; set; } = ""; + public bool Enabled { get; set; } = true; + public List RoleIds { get; set; } = new(); + public string Salt { get; set; } = ""; + public string PasswordHash { get; set; } = ""; +} + +/// rbac.json 的根对象(内存 + 文件持久化)。 +public sealed class RbacSnapshot +{ + public int Version { get; set; } = 1; + public List Roles { get; set; } = new(); + public List Users { get; set; } = new(); +} + +// ─────────────────────────── API DTO ─────────────────────────── + +/// 对外用户视图:绝不含 Salt / PasswordHash。 为该用户可登录的 scope 集合。 +public sealed record RbacUserDto( + string Id, + string Username, + string DisplayName, + bool Enabled, + List RoleIds, + List Scopes); + +public sealed record CreateUserRequest( + string Username, + string? DisplayName, + string Password, + List? RoleIds, + bool Enabled = true); + +public sealed record UpdateUserRequest( + string? DisplayName, + List? RoleIds, + bool? Enabled); + +public sealed record SetPasswordRequest(string Password); + +public sealed record SaveRoleRequest( + string? Id, + string Name, + string? Description, + string Scope, + List? Pages, + List? Ops, + List? WidgetGrants); diff --git a/MiGu.Server/Auth/RbacStore.cs b/MiGu.Server/Auth/RbacStore.cs new file mode 100644 index 0000000..a06dd0c --- /dev/null +++ b/MiGu.Server/Auth/RbacStore.cs @@ -0,0 +1,481 @@ +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) { } +} diff --git a/MiGu.Server/Auth/UserStore.cs b/MiGu.Server/Auth/UserStore.cs deleted file mode 100644 index 8fe00ec..0000000 --- a/MiGu.Server/Auth/UserStore.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.Security.Cryptography; -using System.Text; - -namespace MiGu.Server.Auth; - -/// -/// 最简化的用户表(占位实现):内置 admin / ops 两个账号 + PBKDF2 哈希密码校验。 -/// 真实生产应替换成 Microsoft.AspNetCore.Identity 或外接 LDAP / OAuth。 -/// -/// 安全要点(哪怕是占位也要做到): -/// - 密码不明文存储,启动期用 PBKDF2-SHA256(100k iter, 16B salt) 哈希; -/// - 密码 hash 比较走 防时序攻击; -/// - 不允许「空用户名 = 空密码」之类的快捷绕过。 -/// -/// 默认账号: -/// admin / admin (Platform scope, role-admin) -/// ops / ops (RCSMonitor scope, role-ops) -/// 默认密码同名是为了**开发机一次启动就能登录**;生产部署务必通过环境变量 -/// PLATFORM__AUTH__USERS__<USERNAME>__PASSWORD 改写或接入真实身份源。 -/// -public sealed class UserStore -{ - public sealed record UserRecord( - string Id, - string Username, - string DisplayName, - string DefaultScope, - IReadOnlyList Roles, - byte[] Salt, - byte[] PasswordHash); - - private readonly Dictionary _users; - - public UserStore(IConfiguration config, ILogger logger) - { - _users = new Dictionary(StringComparer.OrdinalIgnoreCase); - - // 1. 内置 admin / ops(密码可从 appsettings 覆盖) - var adminPwd = config["Auth:Users:admin:Password"] ?? "admin"; - var opsPwd = config["Auth:Users:ops:Password"] ?? "ops"; - - Add("u-admin", "admin", "系统管理员", "Platform", new[] { "role-admin", "role-platform-write" }, adminPwd); - Add("u-ops", "ops", "运营人员", "RCSMonitor", new[] { "role-ops", "role-monitor-read" }, opsPwd); - - if (adminPwd == "admin" || opsPwd == "ops") - { - logger.LogWarning( - "UserStore 使用默认弱密码(admin/admin 或 ops/ops)。生产环境务必通过 appsettings.Production.json " + - "或环境变量 PLATFORM__AUTH__USERS__admin__PASSWORD 等覆盖。"); - } - } - - /// 用户名 / 密码校验。返回 null = 不存在或密码错。不向调用方区分两种失败原因,防用户名枚举。 - public UserRecord? Verify(string username, string password) - { - if (string.IsNullOrWhiteSpace(username) || string.IsNullOrEmpty(password)) return null; - if (!_users.TryGetValue(username, out var u)) return null; - - var hash = Pbkdf2(password, u.Salt); - return CryptographicOperations.FixedTimeEquals(hash, u.PasswordHash) ? u : null; - } - - public UserRecord? Find(string username) => - _users.TryGetValue(username ?? "", out var u) ? u : null; - - private void Add(string id, string username, string displayName, string defaultScope, IReadOnlyList roles, string plainPassword) - { - var salt = RandomNumberGenerator.GetBytes(16); - var hash = Pbkdf2(plainPassword, salt); - _users[username] = new UserRecord(id, username, displayName, defaultScope, roles, salt, hash); - } - - private static byte[] Pbkdf2(string password, byte[] salt) => - Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), salt, iterations: 100_000, HashAlgorithmName.SHA256, 32); -} diff --git a/MiGu.Server/Configs/EffectivePermissions.cs b/MiGu.Server/Configs/EffectivePermissions.cs index 19aacc0..86201cd 100644 --- a/MiGu.Server/Configs/EffectivePermissions.cs +++ b/MiGu.Server/Configs/EffectivePermissions.cs @@ -41,4 +41,5 @@ public record EffectivePermissions( string UserId, int Version, List AllowedOps, - List VisibleWidgets); + List VisibleWidgets, + List AllowedPages); diff --git a/MiGu.Server/Controllers/AuthController.cs b/MiGu.Server/Controllers/AuthController.cs index 80a7348..8c5556d 100644 --- a/MiGu.Server/Controllers/AuthController.cs +++ b/MiGu.Server/Controllers/AuthController.cs @@ -44,8 +44,6 @@ public class AuthController : ControllerBase /// 用途:前端路由守卫在受保护路由首次进入前调用 GET /api/auth/me, /// 用 [Authorize] 实校验本地 token 是否仍被服务端接受(MiGu.Server 重启后 /// JWT secret 可能已重生 → 老 token 会被拒),同时刷新 user / scope / runMode / perm。 - /// 与 的区别:不返回 Token(client 已有,重发反而易触发竞态); - /// 不返回 LaunchStatus/Warning(那是登录时一次性的 SimpleLite 拉起结果)。 /// public record MeResponse( AuthUserDto User, @@ -53,25 +51,16 @@ public class AuthController : ControllerBase string RunMode, EffectivePermissions EffectivePermissions); - private static readonly string[] PlatformOps = { "*" }; - - private static readonly string[] RcsOps = - { - "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" - }; - private const string CookieName = "simple.auth.token"; - private readonly UserStore _users; + private readonly RbacStore _rbac; private readonly JwtIssuer _jwt; private readonly SimpleLiteLauncher _launcher; private readonly ILogger _log; - public AuthController(UserStore users, JwtIssuer jwt, SimpleLiteLauncher launcher, ILogger log) + public AuthController(RbacStore rbac, JwtIssuer jwt, SimpleLiteLauncher launcher, ILogger log) { - _users = users; + _rbac = rbac; _jwt = jwt; _launcher = launcher; _log = log; @@ -86,78 +75,40 @@ public class AuthController : ControllerBase if (req.Scope is not ("Platform" or "RCSMonitor")) return BadRequest(new { message = "无效 scope" }); - // AR-3: 真密码校验 —— 替代会话21 点名的「完全不验密码」漏洞。 - // UserStore.Verify 对不存在用户和密码错都返回 null,防用户名枚举。 - var rec = _users.Verify(req.Username, req.Password); - if (rec == null) - return Unauthorized(new { message = "用户名或密码错误" }); + // 真密码校验:RbacStore.VerifyCredentials 对不存在 / 已禁用 / 密码错统一返回 null,防用户名枚举。 + var user = _rbac.VerifyCredentials(req.Username, req.Password); + if (user == null) + return Unauthorized(new { message = "用户名或密码错误,或账号已被停用" }); - // scope 与角色匹配检查:admin 默认 Platform;ops 默认 RCSMonitor。 - // 如果 ops 想登录 Platform scope,目前直接拒绝;后续可以加 role-platform-impersonate 之类。 - if (!CanUseScope(rec, req.Scope)) - return StatusCode(403, new { message = $"账号 {rec.Username} 没有访问 {req.Scope} 的权限" }); + // scope 必须落在该账号「角色覆盖的 scope」集合内(admin 角色 scope=* 覆盖全部)。 + if (!_rbac.CanUseScope(user, req.Scope)) + return StatusCode(403, new { message = $"账号 {user.Username} 没有访问 {req.Scope} 的权限" }); - // 会话 N+1(启动反转):按 LaunchMode 拉起 SimpleLite 子进程。 - // - 历史前端不带该字段 → 默认 DesktopAndWeb(保持向后兼容的 web+local 行为)。 - // - WebOnly → SimpleLite 启动时只起 WebTerminal,不会弹本地桌面窗口。 - // - DesktopAndWeb → SimpleLite 同时起 LocalTerminal + WebTerminal。 - // - SimpleLiteLauncher 内部幂等:第二次/第 N 次登录不会重复拉起;子进程退出后下一次登录可重启。 - // PERF-A8 修复:MaybeStart 内部包含同步 WaitForProjectionReady(最多 ReadinessTimeoutMs,默认 8s)。 - // 走 Task.Run 把它扔到线程池,让登录请求自身的请求处理线程释放回 ASP.NET,避免高并发下挤兑。 + // 会话 N+1:按 LaunchMode 拉起 SimpleLite 子进程(线程池执行,避免占用请求线程)。 var launchMode = NormalizeLaunchMode(req.LaunchMode); SimpleLiteLauncher.LaunchResult? launchResult = null; try { launchResult = await Task.Run(() => _launcher.MaybeStart(launchMode)); _log.LogInformation("SimpleLite launch result for user={User} launchMode={Mode}: Started={Started} Status={Status} Detail={Detail}", - rec.Username, launchMode, launchResult.Value.Started, launchResult.Value.Status, launchResult.Value.Detail); + user.Username, launchMode, launchResult.Value.Started, launchResult.Value.Status, launchResult.Value.Detail); } catch (Exception ex) { - // 启动 SimpleLite 失败不应阻断登录:用户至少能进 Platform 看状态页面排查。 - _log.LogError(ex, "SimpleLite launch threw for user={User} launchMode={Mode}", rec.Username, launchMode); + _log.LogError(ex, "SimpleLite launch threw for user={User} launchMode={Mode}", user.Username, launchMode); } - // A7 修复:runMode 跟 result.Started 走 —— - // - launcher 没起来 / 抛异常 → "Detached",前端显示降级状态而不是"假装 SimpleLite 在跑"。 - // - 跑起来了 → 优先按 launcher 回传的真实 DisplayMode 映射,避免 AlreadyRunning 时被本次请求的 launchMode 误导。 - // - ReusingExisting 时既有 SimpleLite 实际模式未知,保守归 WebEnabled(旧实例最可能带本地窗口)。 var runMode = ResolveRunMode(launchResult, launchMode); - var (allowedOps, widgets) = BuildPermissions(req.Scope); - var perm = new EffectivePermissions(rec.Id, 1, allowedOps.ToList(), widgets.ToList()); - var roles = rec.Roles.Concat(new[] { req.Scope == "Platform" ? "role-platform" : "role-rcs-monitor" }).Distinct().ToList(); - - var token = _jwt.Issue(rec.Id, rec.Username, req.Scope, roles, allowedOps); + var (perm, roleNames, token) = BuildSession(user, req.Scope); SetAuthCookie(token); - var user = new AuthUserDto(rec.Id, rec.Username, rec.DisplayName, roles); - return Ok(new LoginResponse(token, user, req.Scope, runMode, perm, + var dto = new AuthUserDto(user.Id, user.Username, user.DisplayName, roleNames); + return Ok(new LoginResponse(token, dto, req.Scope, runMode, perm, LaunchStatus: launchResult?.Status, LaunchWarning: launchResult?.Warning)); } - /// - /// A7 修复:根据 Launcher 真实结果决定 RunMode。 - /// 之前的逻辑直接按 launchMode 映射,导致 SimpleLite 没起也假装"WebEnabled",前端 RunMode 角标骗人。 - /// - private static string ResolveRunMode(SimpleLiteLauncher.LaunchResult? result, string launchMode) - { - if (result is not { Started: true }) - return "Detached"; - // ReusingExisting 时 DisplayMode = "external",无法确认本地窗口是否存在,保守按 WebEnabled。 - if (result.Value.Status == "ReusingExisting") - return "WebEnabled"; - if (!string.IsNullOrEmpty(result.Value.DisplayMode)) - { - if (result.Value.DisplayMode.Equals("web", StringComparison.OrdinalIgnoreCase)) - return "WebOnly"; - if (result.Value.DisplayMode.Contains("local", StringComparison.OrdinalIgnoreCase)) - return "WebEnabled"; - } - return launchMode == "WebOnly" ? "WebOnly" : "WebEnabled"; - } - [HttpPost("logout")] [AllowAnonymous] public IActionResult Logout() @@ -166,11 +117,7 @@ public class AuthController : ControllerBase return Ok(new { ok = true }); } - /// - /// 用本地持有的 token / Cookie 重新拉一次当前身份。失败(token 过期、签名变更、用户被删等)由 - /// [Authorize] 自动回 401,前端 axios 拦截器在 http.ts:57 会清 localStorage + 跳 /login。 - /// 用途:解决「MiGu.Server 随机 secret 重启 → 老 token 失效 → 前端 isAuthed 仍为 true 误放行」的窗口。 - /// + /// 用本地持有的 token / Cookie 重新拉一次当前身份。失败由 [Authorize] 自动回 401。 [HttpGet("me")] [Authorize] public ActionResult Me() @@ -183,39 +130,20 @@ public class AuthController : ControllerBase if (scope is not ("Platform" or "RCSMonitor")) return Unauthorized(new { message = "无效 scope" }); - var rec = _users.Find(username); - if (rec == null) - return Unauthorized(new { message = "账号已失效" }); + var user = _rbac.FindUser(username); + if (user == null || !user.Enabled) + return Unauthorized(new { message = "账号已失效或被停用" }); - // 账号当前是否还允许这个 scope —— 比如运维把 admin 的角色去掉了,也要在这里及时回 403。 - if (!CanUseScope(rec, scope)) - return StatusCode(403, new { message = $"账号 {rec.Username} 没有访问 {scope} 的权限" }); + // 账号当前是否还允许这个 scope(管理员可能在此期间调整了角色)。 + if (!_rbac.CanUseScope(user, scope)) + return StatusCode(403, new { message = $"账号 {user.Username} 没有访问 {scope} 的权限" }); - var (allowedOps, widgets) = BuildPermissions(scope); - var perm = new EffectivePermissions(rec.Id, 1, allowedOps.ToList(), widgets.ToList()); - var roles = rec.Roles.Concat(new[] { scope == "Platform" ? "role-platform" : "role-rcs-monitor" }) - .Distinct().ToList(); - var user = new AuthUserDto(rec.Id, rec.Username, rec.DisplayName, roles); - - // RunMode 推断:与 SwitchScope 保持一致 —— 复用 Launcher 记录的 LastLaunchMode, - // 不重新拉起 SimpleLite。 - var last = _launcher.LastLaunchMode; - string runMode; - if (string.IsNullOrEmpty(last)) - runMode = "Detached"; - else if (last == SimpleLiteLauncher.ExternalReuseLaunchMode) - runMode = "WebEnabled"; - else - runMode = last.Contains("local", StringComparison.OrdinalIgnoreCase) ? "WebEnabled" : "WebOnly"; - - return Ok(new MeResponse(user, scope, runMode, perm)); + var (perm, roleNames, _) = BuildSession(user, scope); + var dto = new AuthUserDto(user.Id, user.Username, user.DisplayName, roleNames); + return Ok(new MeResponse(dto, scope, InferRunMode(), perm)); } - /// - /// 用同一身份切换 scope 并重发 token + perms。 - /// AR-6: 替代前端 stores/auth.ts 里硬编码改 allowedOps 的客户端伪权限。 - /// 当前服务端只放行账号的 DefaultScope,以及 admin 类账号显式允许的额外 scope。 - /// + /// 用同一身份切换 scope 并重发 token + perms。 [HttpPost("switch-scope")] [Authorize] public ActionResult SwitchScope([FromBody] SwitchScopeRequest req) @@ -227,43 +155,64 @@ public class AuthController : ControllerBase if (string.IsNullOrEmpty(username)) return Unauthorized(new { message = "身份无效" }); - var rec = _users.Find(username); - if (rec == null) - return Unauthorized(new { message = "账号已失效" }); + var user = _rbac.FindUser(username); + if (user == null || !user.Enabled) + return Unauthorized(new { message = "账号已失效或被停用" }); - if (!CanUseScope(rec, req.Scope)) - return StatusCode(403, new { message = $"账号 {rec.Username} 没有访问 {req.Scope} 的权限" }); + if (!_rbac.CanUseScope(user, req.Scope)) + return StatusCode(403, new { message = $"账号 {user.Username} 没有访问 {req.Scope} 的权限" }); - var (allowedOps, widgets) = BuildPermissions(req.Scope); - var perm = new EffectivePermissions(rec.Id, 1, allowedOps.ToList(), widgets.ToList()); - var roles = rec.Roles.Concat(new[] { req.Scope == "Platform" ? "role-platform" : "role-rcs-monitor" }).Distinct().ToList(); - var token = _jwt.Issue(rec.Id, rec.Username, req.Scope, roles, allowedOps); + var (perm, roleNames, token) = BuildSession(user, req.Scope); SetAuthCookie(token); - // 复用首次登录确定的 LaunchMode:SwitchScope 不重新选启动模式(也不应该重启 SimpleLite)。 - // 三种情况: - // 1) Launcher 已记录 "web" / "web+local" → 按 displayMode 反推 runMode。 - // 2) Launcher 记录 ExternalReuseLaunchMode(既有 SimpleLite 复用)→ 模式未知,保守 WebEnabled。 - // 3) Launcher 没拉起 / LastLaunchMode = null → Detached(前端降级展示)。 - var last = _launcher.LastLaunchMode; - string runMode; - if (string.IsNullOrEmpty(last)) - runMode = "Detached"; - else if (last == SimpleLiteLauncher.ExternalReuseLaunchMode) - runMode = "WebEnabled"; - else - runMode = last.Contains("local", StringComparison.OrdinalIgnoreCase) ? "WebEnabled" : "WebOnly"; - - var user = new AuthUserDto(rec.Id, rec.Username, rec.DisplayName, roles); - return Ok(new LoginResponse(token, user, req.Scope, runMode, perm)); + var dto = new AuthUserDto(user.Id, user.Username, user.DisplayName, roleNames); + return Ok(new LoginResponse(token, dto, req.Scope, InferRunMode(), perm)); } public record SwitchScopeRequest(string Scope); + // ───────────────────────── 内部工具 ───────────────────────── + /// - /// 把前端传入的 LaunchMode 归一为枚举字符串("WebOnly" / "DesktopAndWeb")。 - /// null / 空 / 未知值统一退到 "DesktopAndWeb",避免历史前端不带该字段时打破默认行为。 + /// 计算指定 scope 下的有效权限(页面 / 操作 / 控件),并颁发携带该 scope 与 ops 的 JWT。 + /// 这是登录 / me / switchScope 的公共核心,确保三条路径权限计算完全一致。 /// + private (EffectivePermissions perm, List roleNames, string token) BuildSession(RbacUser user, string scope) + { + var eff = _rbac.ComputeEffective(user, scope); + var perm = new EffectivePermissions(user.Id, 1, eff.Ops, eff.Widgets, eff.Pages); + var roleNames = _rbac.RoleNamesOf(user); + // ops claim 写入有效操作码(含可能的 "*"),供 RbacAdmin policy 判定管理权限。 + var token = _jwt.Issue(user.Id, user.Username, scope, roleNames, eff.Ops); + return (perm, roleNames, token); + } + + /// me / switchScope 不重启 SimpleLite,依据 Launcher 记录的 LastLaunchMode 反推 RunMode。 + private string InferRunMode() + { + var last = _launcher.LastLaunchMode; + if (string.IsNullOrEmpty(last)) return "Detached"; + if (last == SimpleLiteLauncher.ExternalReuseLaunchMode) return "WebEnabled"; + return last.Contains("local", StringComparison.OrdinalIgnoreCase) ? "WebEnabled" : "WebOnly"; + } + + /// 根据 Launcher 真实结果决定 RunMode(避免 SimpleLite 没起却假装 WebEnabled)。 + private static string ResolveRunMode(SimpleLiteLauncher.LaunchResult? result, string launchMode) + { + if (result is not { Started: true }) + return "Detached"; + if (result.Value.Status == "ReusingExisting") + return "WebEnabled"; + if (!string.IsNullOrEmpty(result.Value.DisplayMode)) + { + if (result.Value.DisplayMode.Equals("web", StringComparison.OrdinalIgnoreCase)) + return "WebOnly"; + if (result.Value.DisplayMode.Contains("local", StringComparison.OrdinalIgnoreCase)) + return "WebEnabled"; + } + return launchMode == "WebOnly" ? "WebOnly" : "WebEnabled"; + } + private static string NormalizeLaunchMode(string? raw) { return raw?.Trim().ToLowerInvariant() switch @@ -273,44 +222,8 @@ public class AuthController : ControllerBase }; } - private static bool CanUseScope(UserStore.UserRecord rec, string scope) - { - if (scope == rec.DefaultScope) return true; - // 管理员可以下沉到 RCSMonitor 体验运营视角;ops 不能上探 Platform。 - if (scope == "RCSMonitor" && rec.Roles.Contains("role-admin")) return true; - return false; - } - - private static (string[] ops, WidgetGrantDto[] widgets) BuildPermissions(string scope) - { - var ops = scope == "Platform" ? PlatformOps : RcsOps; - var widgets = scope == "Platform" - ? new[] - { - new WidgetGrantDto("MapEditor", "interactive"), - new WidgetGrantDto("CadToolbar", "interactive"), - new WidgetGrantDto("CarPanel", "interactive"), - new WidgetGrantDto("MissionEditor", "interactive"), - new WidgetGrantDto("OpsActionPanel", "interactive"), - new WidgetGrantDto("ConfigCenter", "interactive") - } - : new[] - { - new WidgetGrantDto("MapEditor", "readonly"), - new WidgetGrantDto("CadToolbar", "hidden"), - new WidgetGrantDto("CarPanel", "readonly"), - new WidgetGrantDto("MissionEditor", "readonly"), - new WidgetGrantDto("OpsActionPanel", "interactive"), - new WidgetGrantDto("ConfigCenter", "hidden") - }; - return (ops, widgets); - } - private void SetAuthCookie(string token) { - // AR-5: 同时下发 httpOnly Cookie(XSS 防护)+ Bearer 兼容(旧前端过渡)。 - // SameSite=Lax 足够:管理端 / 监控端均为同源(同一 MiGu.Server 进程), - // 第三方请求不应也无法附带 Cookie;Strict 会让一些 SPA 路由首次刷新认证丢失。 Response.Cookies.Append(CookieName, token, new CookieOptions { HttpOnly = true, diff --git a/MiGu.Server/Controllers/RbacController.cs b/MiGu.Server/Controllers/RbacController.cs new file mode 100644 index 0000000..09f864b --- /dev/null +++ b/MiGu.Server/Controllers/RbacController.cs @@ -0,0 +1,137 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MiGu.Server.Auth; +using MiGu.Server.Configs; + +namespace MiGu.Server.Controllers; + +/// +/// RBAC 管理端:用户 / 角色 / 权限页面分配。整个控制器要求 RbacAdmin 策略 +/// (JWT 的 ops claim 含 *auth.manage),即只有「超级管理员」类账号可访问。 +/// +/// 对应前端「平台配置中心 → 权限与角色」页(/admin/config/auth)。 +/// +[ApiController] +[Authorize(Policy = "RbacAdmin")] +[Route("api/rbac")] +public class RbacController : ControllerBase +{ + public sealed record OpDef(string Code, string Label); + public sealed record WidgetDef(string Id, string Label); + + /// 可分配的操作码候选(管理端配置角色时下拉/勾选用)。 + private static readonly OpDef[] KnownOps = + { + new("*", "全部操作(通配)"), + new("ops.car.pause", "车辆 · 暂停"), + new("ops.car.resume", "车辆 · 恢复"), + new("ops.car.gohome", "车辆 · 回库"), + new("ops.car.resetSession", "车辆 · 重置会话"), + new("ops.car.manualCharge", "车辆 · 手动充电"), + new("ops.task.pause", "任务 · 暂停"), + new("ops.task.cancel", "任务 · 取消"), + new("ops.task.reassign", "任务 · 改派"), + new("ops.task.boostPriority", "任务 · 提升优先级"), + new("monitor.note.write", "监控 · 写运营备注"), + new("auth.manage", "系统 · 权限与角色管理"), + }; + + /// 可配置可见性的控件候选。 + private static readonly WidgetDef[] KnownWidgets = + { + new("MapEditor", "地图编辑器"), + new("CadToolbar", "CAD 工具栏"), + new("CarPanel", "车辆面板"), + new("MissionEditor", "任务编辑器"), + new("OpsActionPanel", "运维操作面板"), + new("ConfigCenter", "配置中心"), + }; + + private readonly RbacStore _store; + private readonly ILogger _log; + + public RbacController(RbacStore store, ILogger log) + { + _store = store; + _log = log; + } + + /// 权限「字典」:页面清单 + 可选操作码 + 可选控件 + scope 选项。前端角色编辑器据此渲染勾选项。 + [HttpGet("catalog")] + public IActionResult Catalog() => Ok(new + { + pages = PageCatalog.All, + ops = KnownOps, + widgets = KnownWidgets, + scopes = new[] + { + new { value = PageCatalog.ScopePlatform, label = "管理端 (Platform)" }, + new { value = PageCatalog.ScopeMonitor, label = "运营端 (RCSMonitor)" }, + new { value = PageCatalog.Wildcard, label = "通用 (全部域)" }, + } + }); + + // ───────────────────────── 角色 ───────────────────────── + + [HttpGet("roles")] + public IActionResult ListRoles() => Ok(_store.ListRoles()); + + [HttpPost("roles")] + public IActionResult CreateRole([FromBody] SaveRoleRequest req) => Guard(() => Ok(_store.CreateRole(req))); + + [HttpPut("roles/{id}")] + public IActionResult UpdateRole(string id, [FromBody] SaveRoleRequest req) => Guard(() => Ok(_store.UpdateRole(id, req))); + + [HttpDelete("roles/{id}")] + public IActionResult DeleteRole(string id) => Guard(() => + { + _store.DeleteRole(id); + return Ok(new { ok = true }); + }); + + // ───────────────────────── 用户 ───────────────────────── + + [HttpGet("users")] + public IActionResult ListUsers() => Ok(_store.ListUsers()); + + [HttpPost("users")] + public IActionResult CreateUser([FromBody] CreateUserRequest req) => Guard(() => Ok(_store.CreateUser(req))); + + [HttpPut("users/{id}")] + public IActionResult UpdateUser(string id, [FromBody] UpdateUserRequest req) => Guard(() => + { + // 自我保护:禁止把当前登录账号自己停用,避免管理员把自己锁在门外。 + if (id == CurrentUserId() && req.Enabled == false) + return (IActionResult)BadRequest(new { message = "不能停用当前登录的账号" }); + return Ok(_store.UpdateUser(id, req)); + }); + + [HttpPut("users/{id}/password")] + public IActionResult SetPassword(string id, [FromBody] SetPasswordRequest req) => Guard(() => + { + _store.SetPassword(id, req.Password); + return Ok(new { ok = true }); + }); + + [HttpDelete("users/{id}")] + public IActionResult DeleteUser(string id) => Guard(() => + { + if (id == CurrentUserId()) + return (IActionResult)BadRequest(new { message = "不能删除当前登录的账号" }); + _store.DeleteUser(id); + return Ok(new { ok = true }); + }); + + // ───────────────────────── 工具 ───────────────────────── + + /// 统一把 翻译成 400 + message,其余异常向上抛。 + private IActionResult Guard(Func action) + { + try { return action(); } + catch (RbacException ex) { return BadRequest(new { message = ex.Message }); } + } + + private string? CurrentUserId() => + User.FindFirstValue("sub") ?? User.FindFirstValue(ClaimTypes.NameIdentifier); +} diff --git a/MiGu.Server/Program.cs b/MiGu.Server/Program.cs index bcd568c..ac559df 100644 --- a/MiGu.Server/Program.cs +++ b/MiGu.Server/Program.cs @@ -94,7 +94,7 @@ builder.Services.AddCors(opts => opts.AddDefaultPolicy(p => // PLATFORM__JWT__SECRET,占位值会被运行时随机化并强制告警。 // - InternalTokenStore 管理 SimpleLite 8222 ↔ MiGu.Server 之间的 X-Platform-Internal-Token // 共享密钥(YARP transform 自动追加)。 -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => { var config = sp.GetRequiredService(); @@ -145,6 +145,14 @@ builder.Services.AddAuthorization(opts => opts.AddPolicy("MonitorScope", p => p.RequireAuthenticatedUser().RequireClaim("scope", "RCSMonitor")); // 任一登录用户。 opts.AddPolicy("AnyAuthed", p => p.RequireAuthenticatedUser()); + // RBAC 管理:JWT 的 ops claim(空格分隔)含 "*" 或 "auth.manage" 才放行。 + // 用于 RbacController(用户 / 角色 / 权限页面管理),即「超级管理员」类账号专属。 + opts.AddPolicy("RbacAdmin", p => p.RequireAuthenticatedUser().RequireAssertion(ctx => + { + var ops = ctx.User.FindFirst("ops")?.Value ?? string.Empty; + var set = ops.Split(' ', StringSplitOptions.RemoveEmptyEntries); + return set.Contains("*") || set.Contains("auth.manage"); + })); }); // YARP + transform:把 Platform 内部 token 透传给 SimpleLite 8222(AR-1/AR-2 配套)。 @@ -176,24 +184,31 @@ var app = builder.Build(); // 启动期主动构造 JwtIssuer / InternalTokenStore:让 secret 校验日志在请求来之前打印。 _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); -// 主动实例化 SimpleLiteLauncher,让 ProcessExit 钩子尽早注册(MiGu.Server 异常退出时 SimpleLite 也会被清理)。 +// 主动构造 RbacStore:首启时尽早 seed 默认用户 / 角色并打印 data/rbac.json 载入日志。 +_ = app.Services.GetRequiredService(); +// 主动实例化 SimpleLiteLauncher(FollowParent=true 时注册 ProcessExit 软关闭钩子)。 var simpleLiteLauncher = app.Services.GetRequiredService(); { var sl = simpleLiteLauncher.GetDiagnostics(); app.Logger.LogInformation( - "[MiGu.Server] SimpleLite: Enabled={Enabled}, ConfiguredPath={Cfg}, Resolved={Resolved}, Exists={Exists}, Port:{Port} reachable={PortUp}. 配置见 appsettings.json → SimpleLite", - sl.Enabled, sl.ConfiguredExecutablePath, sl.ResolvedExecutablePath ?? "(未找到)", sl.ExecutableExists, + "[MiGu.Server] SimpleLite: Enabled={Enabled}, FollowParent={FollowParent}, ConfiguredPath={Cfg}, Resolved={Resolved}, Exists={Exists}, Port:{Port} reachable={PortUp}. 配置见 appsettings.json → SimpleLite", + sl.Enabled, sl.FollowParent, sl.ConfiguredExecutablePath, sl.ResolvedExecutablePath ?? "(未找到)", sl.ExecutableExists, sl.ProjectionPort, sl.ProjectionPortReachable); } -// MiGu.Server 停机时是否带走 SimpleLite,由 SimpleLiteLauncher.Dispose 内部按 FollowParent 决定: -// - 会话 N+2 起 FollowParent=false 默认值 → Dispose 仅释放本地引用,不 kill 子进程(独立程序语义); -// - 仅当用户显式 opt-in FollowParent=true 时,Dispose 才会 kill 子进程 + 关闭 JobObject。 -app.Lifetime.ApplicationStopping.Register(() => +// FollowParent=true 时 MiGu.Server 退出会 kill SimpleLite;默认 false 时不注册停机清理(两进程独立)。 +if (builder.Configuration.GetValue("SimpleLite:FollowParent", false)) { - try { app.Services.GetRequiredService().Dispose(); } - catch { /* shutdown best-effort */ } -}); + app.Lifetime.ApplicationStopping.Register(() => + { + try { app.Services.GetRequiredService().Dispose(); } + catch { /* shutdown best-effort */ } + }); +} +else +{ + app.Logger.LogInformation("[MiGu.Server] SimpleLite: FollowParent=false — MiGu.Server 退出不会结束 SimpleLite"); +} if (app.Environment.IsDevelopment()) {