feat(rbac): RbacStore 持久化权限体系替代硬编码 UserStore

- 新增 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 决定是否注册停机清理钩子
This commit is contained in:
zhaowei.huang
2026-05-29 23:51:08 +08:00
parent 42978930ca
commit a5dd632898
8 changed files with 882 additions and 249 deletions
+75 -162
View File
@@ -44,8 +44,6 @@ public class AuthController : ControllerBase
/// 用途:前端路由守卫在受保护路由首次进入前调用 <c>GET /api/auth/me</c>
/// 用 [Authorize] 实校验本地 token 是否仍被服务端接受(MiGu.Server 重启后
/// JWT secret 可能已重生 → 老 token 会被拒),同时刷新 user / scope / runMode / perm。
/// 与 <see cref="LoginResponse"/> 的区别:不返回 Token(client 已有,重发反而易触发竞态);
/// 不返回 LaunchStatus/Warning(那是登录时一次性的 SimpleLite 拉起结果)。
/// </summary>
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<AuthController> _log;
public AuthController(UserStore users, JwtIssuer jwt, SimpleLiteLauncher launcher, ILogger<AuthController> log)
public AuthController(RbacStore rbac, JwtIssuer jwt, SimpleLiteLauncher launcher, ILogger<AuthController> 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 默认 Platformops 默认 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));
}
/// <summary>
/// A7 修复:根据 Launcher 真实结果决定 RunMode。
/// 之前的逻辑直接按 launchMode 映射,导致 SimpleLite 没起也假装"WebEnabled",前端 RunMode 角标骗人。
/// </summary>
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 });
}
/// <summary>
/// 用本地持有的 token / Cookie 重新拉一次当前身份。失败(token 过期、签名变更、用户被删等)由
/// [Authorize] 自动回 401,前端 axios 拦截器在 http.ts:57 会清 localStorage + 跳 /login。
/// 用途:解决「MiGu.Server 随机 secret 重启 → 老 token 失效 → 前端 isAuthed 仍为 true 误放行」的窗口。
/// </summary>
/// <summary>用本地持有的 token / Cookie 重新拉一次当前身份。失败由 [Authorize] 自动回 401。</summary>
[HttpGet("me")]
[Authorize]
public ActionResult<MeResponse> 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));
}
/// <summary>
/// 用同一身份切换 scope 并重发 token + perms。
/// AR-6: 替代前端 stores/auth.ts 里硬编码改 allowedOps 的客户端伪权限。
/// 当前服务端只放行账号的 DefaultScope,以及 admin 类账号显式允许的额外 scope。
/// </summary>
/// <summary>用同一身份切换 scope 并重发 token + perms。</summary>
[HttpPost("switch-scope")]
[Authorize]
public ActionResult<LoginResponse> 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);
// 复用首次登录确定的 LaunchModeSwitchScope 不重新选启动模式(也不应该重启 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);
// ───────────────────────── 内部工具 ─────────────────────────
/// <summary>
/// 把前端传入的 LaunchMode 归一为枚举字符串("WebOnly" / "DesktopAndWeb"
/// null / / 未知值统一退到 "DesktopAndWeb",避免历史前端不带该字段时打破默认行为
/// 计算指定 scope 下的有效权限(页面 / 操作 / 控件),并颁发携带该 scope 与 ops 的 JWT
/// 这是登录 / me / switchScope 的公共核心,确保三条路径权限计算完全一致
/// </summary>
private (EffectivePermissions perm, List<string> 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);
}
/// <summary>me / switchScope 不重启 SimpleLite,依据 Launcher 记录的 LastLaunchMode 反推 RunMode。</summary>
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";
}
/// <summary>根据 Launcher 真实结果决定 RunMode(避免 SimpleLite 没起却假装 WebEnabled)。</summary>
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 CookieXSS 防护)+ Bearer 兼容(旧前端过渡)。
// SameSite=Lax 足够:管理端 / 监控端均为同源(同一 MiGu.Server 进程),
// 第三方请求不应也无法附带 Cookie;Strict 会让一些 SPA 路由首次刷新认证丢失。
Response.Cookies.Append(CookieName, token, new CookieOptions
{
HttpOnly = true,