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)
});
}
}