using System.Security.Claims; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using MiGu.Server.Auth; using MiGu.Server.Configs; using MiGu.Server.Launcher; namespace MiGu.Server.Controllers; [ApiController] [Route("api/auth")] public class AuthController : ControllerBase { /// /// 登录请求体。 /// 会话 N+1(启动反转):新增 。前端登录页让用户选 "WebOnly" / "DesktopAndWeb"; /// MiGu.Server 据此拉起 SimpleLite 子进程并透传 --display-mode=web|web+local。 /// 历史调用方不传该字段时默认 "DesktopAndWeb"(与之前 web+local 默认行为一致,向后兼容)。 /// public record LoginRequest(string Username, string Password, string Scope, string? LaunchMode = null); /// /// 登录响应。 /// 会话 N+1 增量字段: /// /// RunMode:根据 SimpleLite 真实拉起结果回填(WebEnabled / WebOnly / Detached)。Detached 表示后端未能拉起 SimpleLite,前端可降级展示。 /// LaunchStatus 枚举字符串,前端用于精细化提示。 /// LaunchWarning:可空告警文本;非空时前端应该弹消息条告知用户。 /// /// public record LoginResponse( string Token, AuthUserDto User, string Scope, string RunMode, EffectivePermissions EffectivePermissions, string? LaunchStatus = null, string? LaunchWarning = null); public record AuthUserDto(string Id, string Username, string DisplayName, List Roles); /// /// 当前会话身份的轻量摘要。 /// 用途:前端路由守卫在受保护路由首次进入前调用 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, string Scope, 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 JwtIssuer _jwt; private readonly SimpleLiteLauncher _launcher; private readonly ILogger _log; public AuthController(UserStore users, JwtIssuer jwt, SimpleLiteLauncher launcher, ILogger log) { _users = users; _jwt = jwt; _launcher = launcher; _log = log; } [HttpPost("login")] [AllowAnonymous] public async Task> Login([FromBody] LoginRequest req) { if (string.IsNullOrWhiteSpace(req.Username)) return BadRequest(new { message = "用户名不能为空" }); 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 = "用户名或密码错误" }); // 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} 的权限" }); // 会话 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,避免高并发下挤兑。 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); } catch (Exception ex) { // 启动 SimpleLite 失败不应阻断登录:用户至少能进 Platform 看状态页面排查。 _log.LogError(ex, "SimpleLite launch threw for user={User} launchMode={Mode}", rec.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); SetAuthCookie(token); var user = new AuthUserDto(rec.Id, rec.Username, rec.DisplayName, roles); return Ok(new LoginResponse(token, user, 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() { Response.Cookies.Delete(CookieName); return Ok(new { ok = true }); } /// /// 用本地持有的 token / Cookie 重新拉一次当前身份。失败(token 过期、签名变更、用户被删等)由 /// [Authorize] 自动回 401,前端 axios 拦截器在 http.ts:57 会清 localStorage + 跳 /login。 /// 用途:解决「MiGu.Server 随机 secret 重启 → 老 token 失效 → 前端 isAuthed 仍为 true 误放行」的窗口。 /// [HttpGet("me")] [Authorize] public ActionResult Me() { var username = User.Identity?.Name ?? User.FindFirstValue(ClaimTypes.NameIdentifier); var scope = User.FindFirstValue("scope"); if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(scope)) return Unauthorized(new { message = "身份无效" }); if (scope is not ("Platform" or "RCSMonitor")) return Unauthorized(new { message = "无效 scope" }); var rec = _users.Find(username); if (rec == null) return Unauthorized(new { message = "账号已失效" }); // 账号当前是否还允许这个 scope —— 比如运维把 admin 的角色去掉了,也要在这里及时回 403。 if (!CanUseScope(rec, scope)) return StatusCode(403, new { message = $"账号 {rec.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)); } /// /// 用同一身份切换 scope 并重发 token + perms。 /// AR-6: 替代前端 stores/auth.ts 里硬编码改 allowedOps 的客户端伪权限。 /// 当前服务端只放行账号的 DefaultScope,以及 admin 类账号显式允许的额外 scope。 /// [HttpPost("switch-scope")] [Authorize] public ActionResult SwitchScope([FromBody] SwitchScopeRequest req) { if (req.Scope is not ("Platform" or "RCSMonitor")) return BadRequest(new { message = "无效 scope" }); var username = User.Identity?.Name ?? User.FindFirstValue(ClaimTypes.NameIdentifier); if (string.IsNullOrEmpty(username)) return Unauthorized(new { message = "身份无效" }); var rec = _users.Find(username); if (rec == null) return Unauthorized(new { message = "账号已失效" }); if (!CanUseScope(rec, req.Scope)) return StatusCode(403, new { message = $"账号 {rec.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); 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)); } public record SwitchScopeRequest(string Scope); /// /// 把前端传入的 LaunchMode 归一为枚举字符串("WebOnly" / "DesktopAndWeb")。 /// null / 空 / 未知值统一退到 "DesktopAndWeb",避免历史前端不带该字段时打破默认行为。 /// private static string NormalizeLaunchMode(string? raw) { return raw?.Trim().ToLowerInvariant() switch { "webonly" or "web-only" or "web" => "WebOnly", _ => "DesktopAndWeb", }; } 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, Secure = Request.IsHttps, SameSite = SameSiteMode.Lax, Path = "/", Expires = DateTimeOffset.UtcNow.AddHours(24) }); } }