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())
{