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 { /// /// 登录请求体。可选 ;Simple3 仅 Web 宿主,缺省/未知一律按 WebOnly。 /// MiGu.Server 据此拉起子进程并透传 --display-mode=web。 /// public record LoginRequest(string Username, string Password, string? Scope = null, string? LaunchMode = null); /// /// 登录响应。 /// 会话 N+1 增量字段: /// /// RunMode:根据 Simple3 真实拉起结果回填(WebEnabled / WebOnly / Detached)。Detached 表示后端未能拉起 Simple3,前端可降级展示。 /// LaunchStatus 枚举字符串,前端用于精细化提示。 /// LaunchWarning:可空告警文本;非空时前端应该弹消息条告知用户。 /// /// public record LoginResponse( string Token, AuthUserDto User, string Scope, string RunMode, EffectivePermissions EffectivePermissions, string? LaunchStatus = null, string? LaunchWarning = null, bool NeedsWizard = false); 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。 /// public record MeResponse( AuthUserDto User, string Scope, string RunMode, EffectivePermissions EffectivePermissions, bool NeedsWizard = false, string? Token = null); private const string CookieName = "simple.auth.token"; private readonly RbacStore _rbac; private readonly JwtIssuer _jwt; private readonly Simple3Launcher _launcher; private readonly ConfigStore _config; private readonly ILogger _log; public AuthController(RbacStore rbac, JwtIssuer jwt, Simple3Launcher launcher, ConfigStore config, ILogger log) { _rbac = rbac; _jwt = jwt; _launcher = launcher; _config = config; _log = log; } /// 是否需要进入配置向导(部署画像尚未完成)。登录 / me / switchScope 三处一致回填。 private bool NeedsWizard() => !_config.GetDeployment().Configured; [HttpPost("login")] [AllowAnonymous] public async Task> Login([FromBody] LoginRequest req) { if (string.IsNullOrWhiteSpace(req.Username)) return BadRequest(new { message = "用户名不能为空" }); // 真密码校验:RbacStore.VerifyCredentials 对不存在 / 已禁用 / 密码错统一返回 null,防用户名枚举。 var user = _rbac.VerifyCredentials(req.Username, req.Password); if (user == null) return Unauthorized(new { message = "用户名或密码错误,或账号已被停用" }); // 入口由账号角色决定,不再接受登录页挑选「管理端 / 运营端」。 var scope = _rbac.ResolveLoginScope(user); if (scope == null) return StatusCode(403, new { message = $"账号 {user.Username} 没有任何可登录区域,请联系管理员分配角色" }); // 会话 N+1:按 LaunchMode 拉起 Simple3 子进程(线程池执行,避免占用请求线程)。 var launchMode = NormalizeLaunchMode(req.LaunchMode); Simple3Launcher.LaunchResult? launchResult = null; try { // M1:waitForReady=false —— 拉起 Simple3 后立即返回,不在登录请求里同步等端口 // 就绪(冷启动可能十几秒)。前端拿 LaunchStatus=Starting 即可,必要时轮询健康检查。 launchResult = await Task.Run(() => _launcher.MaybeStart(launchMode, waitForReady: false)); _log.LogInformation("Simple3 launch result for user={User} launchMode={Mode}: Started={Started} Status={Status} Detail={Detail}", user.Username, launchMode, launchResult.Value.Started, launchResult.Value.Status, launchResult.Value.Detail); } catch (Exception ex) { _log.LogError(ex, "Simple3 launch threw for user={User} launchMode={Mode}", user.Username, launchMode); } var runMode = ResolveRunMode(launchResult, launchMode); var (perm, roleNames, token) = BuildSession(user, scope); SetAuthCookie(token); var dto = new AuthUserDto(user.Id, user.Username, user.DisplayName, roleNames); return Ok(new LoginResponse(token, dto, scope, runMode, perm, LaunchStatus: launchResult?.Status, LaunchWarning: launchResult?.Warning, NeedsWizard: NeedsWizard())); } [HttpPost("logout")] [AllowAnonymous] public IActionResult Logout() { Response.Cookies.Delete(CookieName); return Ok(new { ok = true }); } /// 用本地持有的 token / Cookie 重新拉一次当前身份。失败由 [Authorize] 自动回 401。 [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 user = _rbac.FindUser(username); if (user == null || !user.Enabled) return Unauthorized(new { message = "账号已失效或被停用" }); var preferred = _rbac.ResolveLoginScope(user); if (preferred == null) return StatusCode(403, new { message = $"账号 {user.Username} 没有任何可登录区域,请联系管理员分配角色" }); var (perm, roleNames, token) = BuildSession(user, preferred); string? rotated = null; if (!string.Equals(preferred, scope, StringComparison.OrdinalIgnoreCase)) { SetAuthCookie(token); rotated = token; } var dto = new AuthUserDto(user.Id, user.Username, user.DisplayName, roleNames); return Ok(new MeResponse(dto, preferred, InferRunMode(), perm, NeedsWizard(), rotated)); } /// 用同一身份切换 scope 并重发 token + perms。 [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 user = _rbac.FindUser(username); if (user == null || !user.Enabled) return Unauthorized(new { message = "账号已失效或被停用" }); if (!_rbac.CanUseScope(user, req.Scope)) return StatusCode(403, new { message = $"账号 {user.Username} 没有访问 {req.Scope} 的权限" }); // 有管理权限的账号固定走管理入口,不再切到运营壳;请用运营权限账号登录查看运营页。 var preferred = _rbac.ResolveLoginScope(user); if (preferred == PageCatalog.ScopePlatform && req.Scope == PageCatalog.ScopeMonitor) return StatusCode(403, new { message = "请使用运营权限账号查看运营页面" }); var (perm, roleNames, token) = BuildSession(user, req.Scope); SetAuthCookie(token); var dto = new AuthUserDto(user.Id, user.Username, user.DisplayName, roleNames); return Ok(new LoginResponse(token, dto, req.Scope, InferRunMode(), perm, NeedsWizard: NeedsWizard())); } public record SwitchScopeRequest(string Scope); // ───────────────────────── 内部工具 ───────────────────────── /// /// 计算指定 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 pages = DeploymentCatalog.FilterPagesByDeployment(eff.Pages, _config.GetDeployment()); var perm = new EffectivePermissions(user.Id, 1, eff.Ops, eff.Widgets, 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 不重启 Simple3,依据 Launcher 记录的 LastLaunchMode 反推 RunMode。 private string InferRunMode() { var last = _launcher.LastLaunchMode; if (string.IsNullOrEmpty(last)) return "Detached"; if (last == Simple3Launcher.ExternalReuseLaunchMode) return "WebOnly"; return last.Contains("local", StringComparison.OrdinalIgnoreCase) ? "WebEnabled" : "WebOnly"; } /// 根据 Launcher 真实结果决定 RunMode(避免内核没起却假装已连接)。 private static string ResolveRunMode(Simple3Launcher.LaunchResult? result, string launchMode) { if (result is not { Started: true }) return "Detached"; // Simple3 无本地端:复用既有实例也按 WebOnly 展示。 if (result.Value.Status == "ReusingExisting") return "WebOnly"; 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 { "desktopandweb" or "web+local" or "weblocal" or "local" => "DesktopAndWeb", // Simple3 默认仅 Web;未传 / 未知也走 WebOnly _ => "WebOnly", }; } private void SetAuthCookie(string token) { Response.Cookies.Append(CookieName, token, new CookieOptions { HttpOnly = true, Secure = Request.IsHttps, SameSite = SameSiteMode.Lax, Path = "/", Expires = DateTimeOffset.UtcNow.AddHours(24) }); } }