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,
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);
private const string CookieName = "simple.auth.token";
private readonly RbacStore _rbac;
private readonly JwtIssuer _jwt;
private readonly SimpleLiteLauncher _launcher;
private readonly ConfigStore _config;
private readonly ILogger _log;
public AuthController(RbacStore rbac, JwtIssuer jwt, SimpleLiteLauncher 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 = "用户名不能为空" });
if (req.Scope is not ("Platform" or "RCSMonitor"))
return BadRequest(new { message = "无效 scope" });
// 真密码校验:RbacStore.VerifyCredentials 对不存在 / 已禁用 / 密码错统一返回 null,防用户名枚举。
var user = _rbac.VerifyCredentials(req.Username, req.Password);
if (user == null)
return Unauthorized(new { message = "用户名或密码错误,或账号已被停用" });
// scope 必须落在该账号「角色覆盖的 scope」集合内(admin 角色 scope=* 覆盖全部)。
if (!_rbac.CanUseScope(user, req.Scope))
return StatusCode(403, new { message = $"账号 {user.Username} 没有访问 {req.Scope} 的权限" });
// 会话 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}",
user.Username, launchMode, launchResult.Value.Started, launchResult.Value.Status, launchResult.Value.Detail);
}
catch (Exception ex)
{
_log.LogError(ex, "SimpleLite launch threw for user={User} launchMode={Mode}", user.Username, launchMode);
}
var runMode = ResolveRunMode(launchResult, launchMode);
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, 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 = "账号已失效或被停用" });
// 账号当前是否还允许这个 scope(管理员可能在此期间调整了角色)。
if (!_rbac.CanUseScope(user, scope))
return StatusCode(403, new { message = $"账号 {user.Username} 没有访问 {scope} 的权限" });
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, NeedsWizard()));
}
/// 用同一身份切换 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 (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 不重启 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
{
"webonly" or "web-only" or "web" => "WebOnly",
_ => "DesktopAndWeb",
};
}
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)
});
}
}