Files
Migu2.0/MiGu.Server/Controllers/AuthController.cs
T
zhaowei.huangandCursor 42978930ca feat: 迁入 MiGu.Server、平台前端与车辆列表 reflection 回退
从 Simple-FR 拆出 Platform.Server 并重命名为 MiGu.Server;frontends 源码与构建脚本迁入本仓库。地图监控在 projection/cars 失败或为空时回退 reflection 车辆列表;Simple 仓库已移除旧 Platform.Server。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 18:16:34 +08:00

324 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
{
/// <summary>
/// 登录请求体。
/// 会话 N+1(启动反转):新增 <see cref="LaunchMode"/>。前端登录页让用户选 "WebOnly" / "DesktopAndWeb"
/// MiGu.Server 据此拉起 SimpleLite 子进程并透传 <c>--display-mode=web|web+local</c>。
/// 历史调用方不传该字段时默认 "DesktopAndWeb"(与之前 web+local 默认行为一致,向后兼容)。
/// </summary>
public record LoginRequest(string Username, string Password, string Scope, string? LaunchMode = null);
/// <summary>
/// 登录响应。
/// 会话 N+1 增量字段:
/// <list type="bullet">
/// <item><c>RunMode</c>:根据 SimpleLite 真实拉起结果回填(WebEnabled / WebOnly / Detached)。Detached 表示后端未能拉起 SimpleLite,前端可降级展示。</item>
/// <item><c>LaunchStatus</c><see cref="SimpleLiteLauncher.LaunchResult.Status"/> 枚举字符串,前端用于精细化提示。</item>
/// <item><c>LaunchWarning</c>:可空告警文本;非空时前端应该弹消息条告知用户。</item>
/// </list>
/// </summary>
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<string> Roles);
/// <summary>
/// 当前会话身份的轻量摘要。
/// 用途:前端路由守卫在受保护路由首次进入前调用 <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,
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<AuthController> _log;
public AuthController(UserStore users, JwtIssuer jwt, SimpleLiteLauncher launcher, ILogger<AuthController> log)
{
_users = users;
_jwt = jwt;
_launcher = launcher;
_log = log;
}
[HttpPost("login")]
[AllowAnonymous]
public async Task<ActionResult<LoginResponse>> 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 默认 Platformops 默认 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));
}
/// <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()
{
Response.Cookies.Delete(CookieName);
return Ok(new { ok = true });
}
/// <summary>
/// 用本地持有的 token / Cookie 重新拉一次当前身份。失败(token 过期、签名变更、用户被删等)由
/// [Authorize] 自动回 401,前端 axios 拦截器在 http.ts:57 会清 localStorage + 跳 /login。
/// 用途:解决「MiGu.Server 随机 secret 重启 → 老 token 失效 → 前端 isAuthed 仍为 true 误放行」的窗口。
/// </summary>
[HttpGet("me")]
[Authorize]
public ActionResult<MeResponse> 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));
}
/// <summary>
/// 用同一身份切换 scope 并重发 token + perms。
/// AR-6: 替代前端 stores/auth.ts 里硬编码改 allowedOps 的客户端伪权限。
/// 当前服务端只放行账号的 DefaultScope,以及 admin 类账号显式允许的额外 scope。
/// </summary>
[HttpPost("switch-scope")]
[Authorize]
public ActionResult<LoginResponse> 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);
// 复用首次登录确定的 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));
}
public record SwitchScopeRequest(string Scope);
/// <summary>
/// 把前端传入的 LaunchMode 归一为枚举字符串("WebOnly" / "DesktopAndWeb")。
/// null / 空 / 未知值统一退到 "DesktopAndWeb",避免历史前端不带该字段时打破默认行为。
/// </summary>
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 CookieXSS 防护)+ 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)
});
}
}