Files
Migu2.0/MiGu.Server/Program.cs
T
zhaowei.huang a5dd632898 feat(rbac): RbacStore 持久化权限体系替代硬编码 UserStore
- 新增 PageCatalog / RbacModels / RbacStore / RbacController:用户、角色、页面/操作/控件授权落盘 data/rbac.json,支持运行时增删改并即时生效
- 密码改用 PBKDF2-SHA256(100k 迭代 + 16B 随机盐) 存储,校验走 FixedTimeEquals 防时序攻击;对外 DTO 绝不外泄盐/哈希
- AuthController 登录 / me / switch-scope 统一收敛到 BuildSession,按角色在当前 scope 的并集计算有效权限并签发 JWT
- EffectivePermissions 增加 AllowedPages;移除旧的硬编码 UserStore
- Program.cs 注册 RbacStore、新增 RbacAdmin 授权策略(ops claim 含 * 或 auth.manage),并按 SimpleLite:FollowParent 决定是否注册停机清理钩子
2026-05-29 23:51:08 +08:00

239 lines
11 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 Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.OpenApi.Models;
using MiGu.Server.Auth;
using MiGu.Server.Configs;
using MiGu.Server.Launcher;
using Yarp.ReverseProxy.Transforms;
var builder = WebApplication.CreateBuilder(args);
// S2 根治 (会话45):让 MiGu.Server 启动时自动优先把 frontends/apps/simple-platform-vue/dist/
// 作为 WebRoot —— 解决「每次 vite build → wwwroot/index.html 都被 hash 漂移 → 提一个噪音 commit」
// 的死循环。
//
// 探测策略:从 ContentRootPath 出发向上逐级找 frontends/apps/simple-platform-vue/dist/index.html,
// 找到则将 WebRootPath 改指到 dist;找不到则保持默认 wwwroot/(用于「克隆后没跑 build」或
// 生产部署机不带 node 的场景,wwwroot/ 由 build-platform-frontend.bat 的 robocopy /MIR 兜底)。
//
// 影响:
// - dev 模式:跑过一次 `pnpm build` 后无需 robocopy,重启 MiGu.Server 即生效;
// 也不再需要 commit wwwroot/index.html。
// - 部署:CI/CD 走 build-platform-frontend.bat 把 dist 同步到 wwwroot/dist 此时不存在
// 于发布产物中 → 自动 fallback 到 wwwroot/。行为与改造前一致。
{
static string? FindDistRoot(string startDir)
{
var dir = new DirectoryInfo(startDir);
while (dir != null)
{
var candidate = Path.Combine(dir.FullName, "frontends", "apps", "simple-platform-vue", "dist", "index.html");
if (File.Exists(candidate)) return Path.GetDirectoryName(candidate);
dir = dir.Parent;
}
return null;
}
var dist = FindDistRoot(builder.Environment.ContentRootPath);
if (dist != null)
{
builder.Environment.WebRootPath = dist;
Console.WriteLine($"[MiGu.Server] WebRoot -> dist: {dist}");
}
else
{
Console.WriteLine($"[MiGu.Server] WebRoot -> wwwroot (dist not found): {builder.Environment.WebRootPath}");
}
}
// 控制器 + JSON 默认大小写、忽略 null
builder.Services.AddControllers()
.AddJsonOptions(opt =>
{
opt.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
opt.JsonSerializerOptions.DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull;
opt.JsonSerializerOptions.WriteIndented = false;
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new() { Title = "MiGu.Server", Version = "v1", Description = "Simple-FR 平台后端骨架(含 YARP 反代 SimpleLite 8222)。" });
// Swagger 里挂 Bearer 输入框,便于手工测带鉴权的端点。
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Name = "Authorization",
Description = "JWT bearer。值: \"Bearer {token}\"",
In = ParameterLocation.Header,
Type = SecuritySchemeType.ApiKey,
Scheme = "Bearer"
});
c.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" }
},
Array.Empty<string>()
}
});
});
// CORSVite dev 5173 与 MiGu.Server 8080 跨域)
var origins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? Array.Empty<string>();
builder.Services.AddCors(opts => opts.AddDefaultPolicy(p =>
p.WithOrigins(origins).AllowAnyHeader().AllowAnyMethod().AllowCredentials()));
// ─── AR-3 / AR-5JWT + Cookie 双轨鉴权 ─────────────────────────────────────────
//
// 设计:
// - access token 同时通过 Authorization: Bearer header 与 httpOnly Cookie 两路传输;
// - 旧 SPA 还在用 localStorage.token + Bearer,逐步迁移到 Cookie;过渡期两路都接受;
// - JwtIssuer 集中颁发 + 验签;secret 来自 appsettings:Jwt:Secret 或环境变量
// PLATFORM__JWT__SECRET,占位值会被运行时随机化并强制告警。
// - InternalTokenStore 管理 SimpleLite 8222 ↔ MiGu.Server 之间的 X-Platform-Internal-Token
// 共享密钥(YARP transform 自动追加)。
builder.Services.AddSingleton<RbacStore>();
builder.Services.AddSingleton<JwtIssuer>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
var logger = sp.GetRequiredService<ILogger<JwtIssuer>>();
var secret = config["Jwt:Secret"] ?? JwtIssuer.PlaceholderSecret;
var issuer = config["Jwt:Issuer"] ?? "MiGu.Server";
var audience = config["Jwt:Audience"] ?? "platform.client";
var lifetimeMinutes = int.TryParse(config["Jwt:LifetimeMinutes"], out var m) && m > 0 ? m : 24 * 60;
return new JwtIssuer(secret, issuer, audience, TimeSpan.FromMinutes(lifetimeMinutes), logger);
});
builder.Services.AddSingleton<InternalTokenStore>();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(opt =>
{
// TokenValidationParameters 在第一次解析请求时从 JwtIssuer 拿,避免 ctor 顺序耦合。
opt.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
{
// 完整参数在 OnMessageReceived 里替换为 JwtIssuer.BuildValidationParameters()
ValidateIssuer = false,
ValidateAudience = false,
ValidateIssuerSigningKey = false,
ValidateLifetime = false,
};
opt.Events = new JwtBearerEvents
{
OnMessageReceived = ctx =>
{
// 优先从 Authorization: Bearer 取;没有再从 Cookie 取。
if (string.IsNullOrEmpty(ctx.Token))
{
var cookie = ctx.Request.Cookies["simple.auth.token"];
if (!string.IsNullOrEmpty(cookie)) ctx.Token = cookie;
}
// 用真实 JwtIssuer 参数替换占位 ValidationParameters。
var issuer = ctx.HttpContext.RequestServices.GetRequiredService<JwtIssuer>();
ctx.Options.TokenValidationParameters = issuer.BuildValidationParameters();
return Task.CompletedTask;
}
};
});
builder.Services.AddAuthorization(opts =>
{
// Platform scope:完整管理端权限,对应 admin 账号。
opts.AddPolicy("PlatformScope", p => p.RequireAuthenticatedUser().RequireClaim("scope", "Platform"));
// RCSMonitor scope:运营白名单。
opts.AddPolicy("MonitorScope", p => p.RequireAuthenticatedUser().RequireClaim("scope", "RCSMonitor"));
// 任一登录用户。
opts.AddPolicy("AnyAuthed", p => p.RequireAuthenticatedUser());
// RBAC 管理:JWT 的 ops claim(空格分隔)含 "*" 或 "auth.manage" 才放行。
// 用于 RbacController(用户 / 角色 / 权限页面管理),即「超级管理员」类账号专属。
opts.AddPolicy("RbacAdmin", p => p.RequireAuthenticatedUser().RequireAssertion(ctx =>
{
var ops = ctx.User.FindFirst("ops")?.Value ?? string.Empty;
var set = ops.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return set.Contains("*") || set.Contains("auth.manage");
}));
});
// YARP + transform:把 Platform 内部 token 透传给 SimpleLite 8222AR-1/AR-2 配套)。
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
.AddTransforms(tctx =>
{
// 只对 sl-route 注入 internal tokenvrender-routewebVRender iframe 静态资源)不需要。
if (tctx.Route.RouteId != "sl-route") return;
tctx.AddRequestTransform(rt =>
{
var store = rt.HttpContext.RequestServices.GetRequiredService<InternalTokenStore>();
rt.ProxyRequest.Headers.Remove("X-Platform-Internal-Token");
rt.ProxyRequest.Headers.Add("X-Platform-Internal-Token", store.Token);
return ValueTask.CompletedTask;
});
});
// 单例配置仓库(内存 + JSON 文件持久化占位)
builder.Services.AddSingleton<ConfigStore>();
// 会话 N+1(启动反转):把 SimpleLite 子进程拉起器接入 DIAuthController 登录成功后按 LaunchMode 调 MaybeStart。
// 配置段绑定 appsettings.json:SimpleLite,可被环境变量 SIMPLELITE__XXX 覆盖。
builder.Services.Configure<SimpleLiteOptions>(builder.Configuration.GetSection("SimpleLite"));
builder.Services.AddSingleton<SimpleLiteLauncher>();
var app = builder.Build();
// 启动期主动构造 JwtIssuer / InternalTokenStore:让 secret 校验日志在请求来之前打印。
_ = app.Services.GetRequiredService<JwtIssuer>();
_ = app.Services.GetRequiredService<InternalTokenStore>();
// 主动构造 RbacStore:首启时尽早 seed 默认用户 / 角色并打印 data/rbac.json 载入日志。
_ = app.Services.GetRequiredService<RbacStore>();
// 主动实例化 SimpleLiteLauncherFollowParent=true 时注册 ProcessExit 软关闭钩子)。
var simpleLiteLauncher = app.Services.GetRequiredService<SimpleLiteLauncher>();
{
var sl = simpleLiteLauncher.GetDiagnostics();
app.Logger.LogInformation(
"[MiGu.Server] SimpleLite: Enabled={Enabled}, FollowParent={FollowParent}, ConfiguredPath={Cfg}, Resolved={Resolved}, Exists={Exists}, Port:{Port} reachable={PortUp}. 配置见 appsettings.json → SimpleLite",
sl.Enabled, sl.FollowParent, sl.ConfiguredExecutablePath, sl.ResolvedExecutablePath ?? "(未找到)", sl.ExecutableExists,
sl.ProjectionPort, sl.ProjectionPortReachable);
}
// FollowParent=true 时 MiGu.Server 退出会 kill SimpleLite;默认 false 时不注册停机清理(两进程独立)。
if (builder.Configuration.GetValue("SimpleLite:FollowParent", false))
{
app.Lifetime.ApplicationStopping.Register(() =>
{
try { app.Services.GetRequiredService<SimpleLiteLauncher>().Dispose(); }
catch { /* shutdown best-effort */ }
});
}
else
{
app.Logger.LogInformation("[MiGu.Server] SimpleLite: FollowParent=false — MiGu.Server 退出不会结束 SimpleLite");
}
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseCors();
// 静态资源:MiGu.Server/wwwroot 下可同时存放 admin / monitor / index 三份 SPA
app.UseDefaultFiles();
app.UseStaticFiles();
// 鉴权 / 授权管道必须放在 MapControllers 之前;CORS 之后。
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
// YARP/api/sl/* → :8222/vr/* → :8223
app.MapReverseProxy();
// SPA fallback:单一合并 Vue 工程(vue-router 处理 /admin/* /monitor/* /login /status
// 所有非 API、非静态资源的路径都返回 wwwroot/index.html
app.MapFallbackToFile("index.html");
app.Run();