配置段/环境变量、Launcher、健康检查 API、OpenAPI 与前后端文案同步;兼容探测旧 SimpleLite 进程名。 Co-authored-by: Cursor <cursoragent@cursor.com>
262 lines
12 KiB
C#
262 lines
12 KiB
C#
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>
|
||
/// 登录请求体。可选 <see cref="LaunchMode"/>;Simple3 仅 Web 宿主,缺省/未知一律按 WebOnly。
|
||
/// MiGu.Server 据此拉起子进程并透传 <c>--display-mode=web</c>。
|
||
/// </summary>
|
||
public record LoginRequest(string Username, string Password, string? Scope = null, string? LaunchMode = null);
|
||
|
||
/// <summary>
|
||
/// 登录响应。
|
||
/// 会话 N+1 增量字段:
|
||
/// <list type="bullet">
|
||
/// <item><c>RunMode</c>:根据 Simple3 真实拉起结果回填(WebEnabled / WebOnly / Detached)。Detached 表示后端未能拉起 Simple3,前端可降级展示。</item>
|
||
/// <item><c>LaunchStatus</c>:<see cref="Simple3Launcher.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,
|
||
bool NeedsWizard = false);
|
||
|
||
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。
|
||
/// </summary>
|
||
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<AuthController> _log;
|
||
|
||
public AuthController(RbacStore rbac, JwtIssuer jwt, Simple3Launcher launcher, ConfigStore config, ILogger<AuthController> log)
|
||
{
|
||
_rbac = rbac;
|
||
_jwt = jwt;
|
||
_launcher = launcher;
|
||
_config = config;
|
||
_log = log;
|
||
}
|
||
|
||
/// <summary>是否需要进入配置向导(部署画像尚未完成)。登录 / me / switchScope 三处一致回填。</summary>
|
||
private bool NeedsWizard() => !_config.GetDeployment().Configured;
|
||
|
||
[HttpPost("login")]
|
||
[AllowAnonymous]
|
||
public async Task<ActionResult<LoginResponse>> 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 });
|
||
}
|
||
|
||
/// <summary>用本地持有的 token / Cookie 重新拉一次当前身份。失败由 [Authorize] 自动回 401。</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 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));
|
||
}
|
||
|
||
/// <summary>用同一身份切换 scope 并重发 token + perms。</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 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);
|
||
|
||
// ───────────────────────── 内部工具 ─────────────────────────
|
||
|
||
/// <summary>
|
||
/// 计算指定 scope 下的有效权限(页面 / 操作 / 控件),并颁发携带该 scope 与 ops 的 JWT。
|
||
/// 这是登录 / me / switchScope 的公共核心,确保三条路径权限计算完全一致。
|
||
/// </summary>
|
||
private (EffectivePermissions perm, List<string> 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);
|
||
}
|
||
|
||
/// <summary>me / switchScope 不重启 Simple3,依据 Launcher 记录的 LastLaunchMode 反推 RunMode。</summary>
|
||
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";
|
||
}
|
||
|
||
/// <summary>根据 Launcher 真实结果决定 RunMode(避免内核没起却假装已连接)。</summary>
|
||
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)
|
||
});
|
||
}
|
||
}
|