调整 .gitignore 以适配 backends 目录结构

将 MiGu.Server 相关忽略规则迁移至 backends/MiGu.Server,并新增对 .tmp-build* 和 /.cursor/rules 的忽略,优化敏感数据与临时文件的管理。
This commit is contained in:
ArtoriasWu
2026-06-22 08:55:55 +08:00
parent 45f0261ed7
commit 87629a8537
46 changed files with 10 additions and 3 deletions
-82
View File
@@ -1,82 +0,0 @@
using System.Security.Cryptography;
namespace MiGu.Server.Auth;
/// <summary>
/// MiGu.Server ↔ SimpleLite 8222 之间的 **内部共享 token**。
///
/// 设计动机:SimpleLite 8222 的 EmbedIO WebApiReflectionApi / PersistenceApi / MapEditApi
/// 历史上完全无鉴权,远程访问 = 接管调度内核。为不在 SimpleLite 侧实现完整 JWT 验签
/// (减少 SimpleLite 复杂度),采用一个轻量约定:
///
/// - <b>本机回环</b>127.0.0.1 / ::1SimpleLite 直接放行(开发机直连不受影响);
/// - <b>其它来源</b>必须携带 <c>X-Platform-Internal-Token</c> header
/// - MiGu.Server YARP 反代 <c>/api/sl/*</c> 到 SimpleLite 8222 时,YARP transform
/// 会自动追加该 header
/// - SimpleLite 端通过 <c>simple.json:platform.internalToken</c> 或环境变量
/// <c>SIMPLELITE__PLATFORM__INTERNALTOKEN</c> 配置同一个 token;两端不一致即拒绝。
///
/// Token 来源优先级:
/// 1) appsettings.json:Internal:Token 显式配置(生产推荐:strong, length ≥ 32
/// 2) 环境变量 PLATFORM__INTERNAL__TOKEN
/// 3) 兜底:进程随机 64 字节 base64,并在日志告警 + 写入 <c>data/.internal-token</c>
/// 文件供本机 SimpleLite 读取(同机部署常见场景)
/// </summary>
public sealed class InternalTokenStore
{
public string Token { get; }
public bool IsEphemeral { get; }
public string? PersistedFilePath { get; }
public InternalTokenStore(IConfiguration config, IWebHostEnvironment env, ILogger<InternalTokenStore> logger)
{
var configured = config["Internal:Token"];
if (!string.IsNullOrWhiteSpace(configured) && configured != "REPLACE_ME")
{
Token = configured;
IsEphemeral = false;
logger.LogInformation("Internal token 从配置读取(长度 {Len})。", Token.Length);
return;
}
// 兜底:进程随机 + 落地到 data/.internal-token 便于同机 SimpleLite 读取
var dataDir = Path.Combine(env.ContentRootPath, "data");
Directory.CreateDirectory(dataDir);
var file = Path.Combine(dataDir, ".internal-token");
if (File.Exists(file))
{
try
{
var existing = File.ReadAllText(file).Trim();
if (existing.Length >= 32)
{
Token = existing;
IsEphemeral = false;
PersistedFilePath = file;
logger.LogInformation("Internal token 复用 {File}(长度 {Len})。", file, Token.Length);
return;
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "读取 {File} 失败,将重新生成 internal token。", file);
}
}
Token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(48));
IsEphemeral = true;
try
{
File.WriteAllText(file, Token);
PersistedFilePath = file;
logger.LogWarning(
"Internal token 未配置 —— 已生成进程随机值并写入 {File}(重启后保留)。" +
"若 MiGu.Server 与 SimpleLite 不在同一台机器,请把同一个值写入 SimpleLite 端 simple.json:platform.internalToken。",
file);
}
catch (Exception ex)
{
logger.LogError(ex, "internal token 落盘失败 —— 远程 SimpleLite 调用将无法通过鉴权(每次重启 token 都不同)。");
}
}
}
-100
View File
@@ -1,100 +0,0 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Microsoft.IdentityModel.Tokens;
namespace MiGu.Server.Auth;
/// <summary>
/// 平台 JWT 颁发与验签。Secret 从 <c>appsettings.json</c> 的 <c>Jwt:Secret</c> 读取;
/// 若为占位值 <c>"REPLACE_ME"</c> 则启动期生成临时随机 secret 并在日志里强制告警,
/// 防止生产部署忘改 secret 导致历史 hardcoded "simple-mock-secret" 风险复现。
/// </summary>
public sealed class JwtIssuer
{
/// <summary>占位 secret;启动期检测到时自动换成进程随机值,并在日志里 critical 告警。</summary>
public const string PlaceholderSecret = "REPLACE_ME";
public string Issuer { get; }
public string Audience { get; }
public TimeSpan Lifetime { get; }
public string SecretInUse { get; }
public bool SecretIsEphemeral { get; }
private readonly SymmetricSecurityKey _key;
private readonly SigningCredentials _signing;
private readonly TokenValidationParameters _validation;
public JwtIssuer(string secret, string issuer, string audience, TimeSpan lifetime, ILogger<JwtIssuer> logger)
{
Issuer = issuer;
Audience = audience;
Lifetime = lifetime;
if (string.IsNullOrWhiteSpace(secret) || secret == PlaceholderSecret)
{
// 兜底:用进程随机 secret,保证签名不被预知,但 token 在 MiGu.Server 重启后失效。
// 这条路径只应出现在「开发机首次启动」/「忘改配置的部署」,**日志里强制告警让运维注意**。
secret = RandomBase64Secret(64);
SecretIsEphemeral = true;
logger.LogCritical(
"Jwt:Secret 是占位值或未配置 —— 已生成进程随机 secret(重启后所有 token 失效)。" +
"生产环境必须在 appsettings.Production.json 或环境变量 PLATFORM__JWT__SECRET 配置一个 ≥ 32 字节的稳定 secret。");
}
else if (Encoding.UTF8.GetByteCount(secret) < 32)
{
logger.LogWarning("Jwt:Secret 长度不足 32 字节,HS256 推荐 ≥ 32 字节 secret 以达到 256bit 强度。");
}
SecretInUse = secret;
_key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret));
_signing = new SigningCredentials(_key, SecurityAlgorithms.HmacSha256);
_validation = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateIssuerSigningKey = true,
ValidateLifetime = true,
ValidIssuer = Issuer,
ValidAudience = Audience,
IssuerSigningKey = _key,
ClockSkew = TimeSpan.FromSeconds(30),
};
}
/// <summary>颁发 access token。<paramref name="ops"/> 写入私有 claim <c>ops</c>(空格分隔,便于后端 <c>[Authorize]</c> policy 解析)。</summary>
public string Issue(string userId, string username, string scope, IReadOnlyList<string> roles, IReadOnlyList<string> ops)
{
var now = DateTime.UtcNow;
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, userId),
new(JwtRegisteredClaimNames.UniqueName, username),
new("scope", scope),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")),
new(JwtRegisteredClaimNames.Iat, new DateTimeOffset(now).ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64),
new("ops", string.Join(' ', ops ?? Array.Empty<string>())),
};
foreach (var r in roles ?? Array.Empty<string>())
claims.Add(new Claim(ClaimTypes.Role, r));
var token = new JwtSecurityToken(
issuer: Issuer,
audience: Audience,
claims: claims,
notBefore: now,
expires: now.Add(Lifetime),
signingCredentials: _signing);
return new JwtSecurityTokenHandler().WriteToken(token);
}
/// <summary>给框架的 JwtBearer middleware 用的验证参数(启动期注入到 AddJwtBearer)。</summary>
public TokenValidationParameters BuildValidationParameters() => _validation;
private static string RandomBase64Secret(int byteLen)
{
var buf = RandomNumberGenerator.GetBytes(byteLen);
return Convert.ToBase64String(buf);
}
}
-99
View File
@@ -1,99 +0,0 @@
namespace MiGu.Server.Auth;
/// <summary>
/// 单个「权限页面」定义。Key 与前端 vue-router 的 route.name 一一对齐,
/// 角色通过勾选 Key 集合决定可访问的页面(菜单 + 路由守卫据此放行)。
/// </summary>
public sealed record PageDef(string Key, string Label, string Group, string Scope);
/// <summary>
/// 平台「权限页面清单」——RBAC 的最小授权单元。
///
/// 设计:页面是由前端路由静态决定的(相对稳定),因此后端维护一份与
/// <c>frontends/.../router/index.ts</c> 对齐的静态清单,通过 <c>GET /api/rbac/pages</c>
/// 暴露给「权限与角色」管理页,让管理员可视化地把页面分配给角色。
///
/// Scope 含义:
/// - <c>Platform</c>:管理端(/admin/*)页面;
/// - <c>RCSMonitor</c>:运营监控端(/monitor/*)页面。
/// </summary>
public static class PageCatalog
{
public const string ScopePlatform = "Platform";
public const string ScopeMonitor = "RCSMonitor";
/// <summary>权限页面通配符:角色 Pages 含此值表示「该 scope 下全部页面」(超级管理员)。</summary>
public const string Wildcard = "*";
public static readonly IReadOnlyList<PageDef> All = new List<PageDef>
{
// ── 管理端 / Platform:概览 ──
new("admin-dashboard", "总览", "概览", ScopePlatform),
new("admin-map-monitor", "地图监控", "概览", ScopePlatform),
// ── 管理端 / Platform:设计与编排 ──
new("admin-maps", "地图管理", "设计与编排", ScopePlatform),
new("admin-map-editor", "地图编辑", "设计与编排", ScopePlatform),
new("admin-project-properties", "项目属性", "设计与编排", ScopePlatform),
new("admin-tracks", "场景管理", "设计与编排", ScopePlatform),
new("admin-cars", "车辆管理", "设计与编排", ScopePlatform),
new("admin-processes", "进程管理", "设计与编排", ScopePlatform),
new("admin-scripts", "脚本管理", "设计与编排", ScopePlatform),
new("admin-task-templates", "任务编排", "设计与编排", ScopePlatform),
// ── 管理端 / Platform:平台配置中心(聚合页,每个 Key 对齐前端聚合路由 route.name) ──
new("admin-config-strategy", "调度策略", "平台配置中心", ScopePlatform),
new("admin-vehicle-hub", "车辆运维", "平台配置中心", ScopePlatform),
new("admin-config-facility", "设备与库位", "平台配置中心", ScopePlatform),
new("admin-config-business", "业务与集成", "平台配置中心", ScopePlatform),
new("admin-config-ops-center", "运维与回放", "平台配置中心", ScopePlatform),
new("admin-config-system-center", "系统与权限", "平台配置中心", ScopePlatform),
// ── 运营端 / RCSMonitor ──
new("monitor-dashboard", "运营总览", "运营监控", ScopeMonitor),
new("monitor-vehicle-hub", "车辆运维", "运营监控", ScopeMonitor),
new("monitor-map", "地图监控", "运营监控", ScopeMonitor),
new("monitor-ops", "运维操作", "运营监控", ScopeMonitor),
new("monitor-notes", "运营备注", "运营监控", ScopeMonitor),
};
private static readonly HashSet<string> _keys =
All.Select(p => p.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
/// <summary>已下线页面 Key → 合并后的新 Key(角色数据迁移用)。</summary>
private static readonly IReadOnlyDictionary<string, string> LegacyKeyAliases =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["admin-config-vehicle"] = "admin-vehicle-hub",
["admin-config-fleet"] = "admin-vehicle-hub",
// 会话 16:平台配置中心入口按业务收敛为 6 个聚合页,旧 Key 迁移到对应聚合页 Key。
["admin-config-routing"] = "admin-config-strategy",
["admin-config-task"] = "admin-config-strategy",
["admin-config-traffic"] = "admin-config-strategy",
["admin-config-charge"] = "admin-config-strategy",
["admin-config-device"] = "admin-config-facility",
["admin-config-location"] = "admin-config-facility",
["admin-config-integrations"] = "admin-config-business",
["admin-config-scenario"] = "admin-config-business",
["admin-config-widget"] = "admin-config-business",
["admin-playback"] = "admin-config-ops-center",
["admin-config-ops"] = "admin-config-ops-center",
["admin-config-logs"] = "admin-config-ops-center",
["admin-config-map-monitor"] = "admin-config-ops-center",
["admin-config-system"] = "admin-config-system-center",
["admin-config-auth"] = "admin-config-system-center",
};
/// <summary>判断页面 Key 是否合法(用于角色保存时过滤掉脏数据 / 已下线页面)。</summary>
public static bool IsValidKey(string key) => _keys.Contains(key);
/// <summary>将旧页面 Key 映射为当前有效 Key;未知 Key 原样返回。</summary>
public static string NormalizeKey(string key) =>
LegacyKeyAliases.TryGetValue(key, out var mapped) ? mapped : key;
/// <summary>列出某 scope 下的全部页面 Key(用于把角色的 "*" 通配展开成具体页面集合)。</summary>
public static IReadOnlyList<string> KeysForScope(string scope) =>
All.Where(p => string.Equals(p.Scope, scope, StringComparison.OrdinalIgnoreCase))
.Select(p => p.Key)
.ToList();
}
-82
View File
@@ -1,82 +0,0 @@
using MiGu.Server.Configs;
namespace MiGu.Server.Auth;
/// <summary>
/// RBAC 角色。一个角色 = 一组「页面 + 操作码 + 控件可见性」授权,归属某个 scope。
/// <list type="bullet">
/// <item><see cref="Scope"/><c>Platform</c> / <c>RCSMonitor</c> / <c>*</c>(通用,对两个 scope 都生效)。</item>
/// <item><see cref="Pages"/>:可访问页面 Key 集合(见 <see cref="PageCatalog"/>);含 <c>*</c> 表示该 scope 全部页面。</item>
/// <item><see cref="Ops"/>:细粒度操作码(如 <c>ops.car.pause</c>);含 <c>*</c> 表示全部操作。</item>
/// <item><see cref="WidgetGrants"/>:控件级可见性(hidden / readonly / interactive)。</item>
/// <item><see cref="System"/>:内置系统角色,禁止删除(可改名/调权限但保底不被误删)。</item>
/// </list>
/// </summary>
public sealed class RbacRole
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public string Description { get; set; } = "";
public string Scope { get; set; } = PageCatalog.ScopePlatform;
public List<string> Pages { get; set; } = new();
public List<string> Ops { get; set; } = new();
public List<WidgetGrantDto> WidgetGrants { get; set; } = new();
public bool System { get; set; }
}
/// <summary>
/// RBAC 用户。密码以 PBKDF2-SHA256 哈希存储(<see cref="Salt"/> / <see cref="PasswordHash"/> 均为 base64)。
/// 一个用户可拥有多个角色,其有效权限 = 当前 scope 下各角色授权的并集。
/// </summary>
public sealed class RbacUser
{
public string Id { get; set; } = "";
public string Username { get; set; } = "";
public string DisplayName { get; set; } = "";
public bool Enabled { get; set; } = true;
public List<string> RoleIds { get; set; } = new();
public string Salt { get; set; } = "";
public string PasswordHash { get; set; } = "";
}
/// <summary>rbac.json 的根对象(内存 + 文件持久化)。</summary>
public sealed class RbacSnapshot
{
public int Version { get; set; } = 1;
public List<RbacRole> Roles { get; set; } = new();
public List<RbacUser> Users { get; set; } = new();
}
// ─────────────────────────── API DTO ───────────────────────────
/// <summary>对外用户视图:绝不含 Salt / PasswordHash。<see cref="Scopes"/> 为该用户可登录的 scope 集合。</summary>
public sealed record RbacUserDto(
string Id,
string Username,
string DisplayName,
bool Enabled,
List<string> RoleIds,
List<string> Scopes);
public sealed record CreateUserRequest(
string Username,
string? DisplayName,
string Password,
List<string>? RoleIds,
bool Enabled = true);
public sealed record UpdateUserRequest(
string? DisplayName,
List<string>? RoleIds,
bool? Enabled);
public sealed record SetPasswordRequest(string Password);
public sealed record SaveRoleRequest(
string? Id,
string Name,
string? Description,
string Scope,
List<string>? Pages,
List<string>? Ops,
List<WidgetGrantDto>? WidgetGrants);
-551
View File
@@ -1,551 +0,0 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using MiGu.Server.Configs;
using MiGu.Server.Infra;
namespace MiGu.Server.Auth;
/// <summary>
/// RBAC 权威存储:用户 + 角色 + 权限页面授权,内存态 + <c>data/rbac.json</c> 文件持久化。
///
/// 取代旧的硬编码 <c>UserStore</c> + <c>AuthController.BuildPermissions</c>
/// - 登录密码校验、角色解析、有效权限(页面 / 操作 / 控件)全部由本类计算;
/// - 管理端「权限与角色」页通过 <c>RbacController</c> 增删改用户 / 角色,落盘后即时生效;
/// - 密码以 PBKDF2-SHA256(100k, 16B salt) 哈希存储,比较走 FixedTimeEquals 防时序攻击。
///
/// 首次启动(rbac.json 不存在)时 seed 两个内置账号:
/// admin(超级管理员,scope=*,全部页面 / 操作)
/// ops (运营人员,scope=RCSMonitor,运营四页 + 运维操作码)
/// 初始密码取 appsettings <c>Auth:Users:{name}:Password</c>,缺省 admin/ops(开发弱口令,生产须改)。
/// </summary>
public sealed class RbacStore
{
public sealed record EffectiveResult(List<string> Pages, List<string> Ops, List<WidgetGrantDto> Widgets);
private const string RoleAdminId = "role-admin";
private const string RoleOpsId = "role-ops";
private readonly object _gate = new();
private readonly string _file;
private readonly ILogger<RbacStore> _logger;
private readonly JsonSerializerOptions _jsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
private RbacSnapshot _snapshot = new();
public RbacStore(IConfiguration config, IWebHostEnvironment env, ILogger<RbacStore> logger)
{
_logger = logger;
var dataDir = Path.Combine(env.ContentRootPath, "data");
Directory.CreateDirectory(dataDir);
_file = Path.Combine(dataDir, "rbac.json");
Load(config);
}
// ───────────────────────── 加载 / 持久化 ─────────────────────────
private void Load(IConfiguration config)
{
if (File.Exists(_file))
{
try
{
var json = File.ReadAllText(_file);
var snap = JsonSerializer.Deserialize<RbacSnapshot>(json, _jsonOpts);
if (snap is { Users.Count: > 0 })
{
_snapshot = Normalize(snap);
_logger.LogInformation("RBAC 从 {File} 载入:{Users} 用户 / {Roles} 角色。",
_file, _snapshot.Users.Count, _snapshot.Roles.Count);
return;
}
_logger.LogWarning("RBAC 文件 {File} 内容为空或无用户,回退到默认 seed。", _file);
}
catch (Exception ex)
{
_logger.LogError(ex, "RBAC 文件 {File} 解析失败,回退到默认 seed。", _file);
// S2:先备份疑似损坏的 rbac.json,避免随后默认 seed 的写入把自定义用户 / 角色
// 永久冲掉(损坏 → 静默重置成 admin/ops 弱口令是高危场景)。
var bak = AtomicFile.BackupCorrupt(_file);
if (bak != null)
_logger.LogWarning("已备份疑似损坏的 RBAC 文件到 {Backup},请人工核查后恢复自定义数据。", bak);
}
}
_snapshot = SeedDefault(config);
Persist();
_logger.LogInformation("RBAC 已生成默认数据并写入 {File}admin / ops)。", _file);
}
/// <summary>清洗加载结果:补默认、去重、过滤非法页面 Key,保证内置角色存在。</summary>
private static RbacSnapshot Normalize(RbacSnapshot snap)
{
snap.Roles ??= new();
snap.Users ??= new();
foreach (var r in snap.Roles)
{
r.Pages = (r.Pages ?? new())
.Select(p => p == PageCatalog.Wildcard ? p : PageCatalog.NormalizeKey(p))
.Where(p => p == PageCatalog.Wildcard || PageCatalog.IsValidKey(p))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
r.Ops = (r.Ops ?? new()).Distinct().ToList();
r.WidgetGrants ??= new();
if (string.IsNullOrWhiteSpace(r.Scope)) r.Scope = PageCatalog.ScopePlatform;
BackfillKnownPageMigrations(r);
}
foreach (var u in snap.Users)
{
u.RoleIds = (u.RoleIds ?? new()).Distinct().ToList();
}
return snap;
}
private static void BackfillKnownPageMigrations(RbacRole r)
{
if (!string.Equals(r.Scope, PageCatalog.ScopePlatform, StringComparison.OrdinalIgnoreCase)
&& r.Scope != PageCatalog.Wildcard) return;
// 任务编排页(admin-task-templates,原任务模板 / WorkflowEditor)与脚本、进程管理同属一组编排能力。
// 旧的 admin-missions 入口已下线:上面 Normalize 会按 PageCatalog 过滤掉历史角色里的该 key。
// 这里在角色已有进程 + 脚本入口时补齐「任务编排」菜单权限。
var hasProcessAndScript =
r.Pages.Contains("admin-processes", StringComparer.OrdinalIgnoreCase)
&& r.Pages.Contains("admin-scripts", StringComparer.OrdinalIgnoreCase);
if (!r.Pages.Contains(PageCatalog.Wildcard)
&& !r.Pages.Contains("admin-task-templates", StringComparer.OrdinalIgnoreCase)
&& hasProcessAndScript)
r.Pages.Add("admin-task-templates");
}
private RbacSnapshot SeedDefault(IConfiguration config)
{
var adminPwd = config["Auth:Users:admin:Password"] ?? "admin";
var opsPwd = config["Auth:Users:ops:Password"] ?? "ops";
if (adminPwd == "admin" || opsPwd == "ops")
_logger.LogWarning("RBAC seed 使用默认弱密码(admin/ops),生产环境请尽快在「权限与角色」页修改或通过环境变量覆盖。");
var snap = new RbacSnapshot
{
Version = 1,
Roles = new List<RbacRole>
{
new()
{
Id = RoleAdminId, Name = "超级管理员", Description = "拥有全部页面与操作权限的内置角色",
Scope = PageCatalog.Wildcard,
Pages = new() { PageCatalog.Wildcard },
Ops = new() { "*" },
WidgetGrants = new(),
System = true
},
new()
{
Id = RoleOpsId, Name = "运营人员", Description = "运营监控端默认角色:可执行运维操作、查看监控",
Scope = PageCatalog.ScopeMonitor,
Pages = new() { "monitor-dashboard", "monitor-vehicle-hub", "monitor-map", "monitor-ops", "monitor-notes" },
Ops = new()
{
"ops.car.pause", "ops.car.resume", "ops.car.gohome", "ops.car.resetSession",
"ops.car.manualCharge", "ops.task.pause", "ops.task.cancel", "ops.task.reassign",
"ops.task.boostPriority", "monitor.note.write"
},
WidgetGrants = new()
{
new("MapEditor", "readonly"),
new("CadToolbar", "hidden"),
new("CarPanel", "readonly"),
new("MissionEditor", "readonly"),
new("OpsActionPanel", "interactive"),
new("ConfigCenter", "hidden")
},
System = true
}
},
Users = new List<RbacUser>()
};
snap.Users.Add(NewUser("u-admin", "admin", "系统管理员", adminPwd, new() { RoleAdminId }));
snap.Users.Add(NewUser("u-ops", "ops", "运营人员", opsPwd, new() { RoleOpsId }));
return snap;
}
private void Persist()
{
try
{
AtomicFile.WriteAllText(_file, JsonSerializer.Serialize(_snapshot, _jsonOpts));
}
catch (Exception ex)
{
_logger.LogError(ex, "RBAC 持久化到 {File} 失败。", _file);
}
}
// ───────────────────────── 登录 / 鉴权读取 ─────────────────────────
/// <summary>用户名 + 密码校验。返回 null = 不存在 / 已禁用 / 密码错(不区分原因,防用户名枚举)。</summary>
public RbacUser? VerifyCredentials(string username, string password)
{
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrEmpty(password)) return null;
lock (_gate)
{
var u = FindByName(username);
if (u is null || !u.Enabled) return null;
if (!VerifyHash(password, u.Salt, u.PasswordHash)) return null;
return Clone(u);
}
}
public RbacUser? FindUser(string username)
{
lock (_gate) { var u = FindByName(username); return u is null ? null : Clone(u); }
}
/// <summary>当前用户可登录的 scope 集合(其角色覆盖的 scope<c>*</c> 角色覆盖全部)。</summary>
public List<string> UsableScopes(RbacUser user)
{
lock (_gate)
{
var roles = RolesOf(user);
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var r in roles)
{
if (r.Scope == PageCatalog.Wildcard)
{
set.Add(PageCatalog.ScopePlatform);
set.Add(PageCatalog.ScopeMonitor);
}
else set.Add(r.Scope);
}
return set.ToList();
}
}
public bool CanUseScope(RbacUser user, string scope) =>
UsableScopes(user).Contains(scope, StringComparer.OrdinalIgnoreCase);
/// <summary>角色名(展示用,写入 AuthUserDto.Roles / JWT role claim)。</summary>
public List<string> RoleNamesOf(RbacUser user)
{
lock (_gate) { return RolesOf(user).Select(r => r.Name).ToList(); }
}
/// <summary>
/// 计算用户在指定 scope 下的有效权限:可访问页面、操作码、控件可见性,均取适用角色的并集。
/// 适用角色 = 角色 scope 等于该 scope,或角色 scope 为通配 <c>*</c>。
/// </summary>
public EffectiveResult ComputeEffective(RbacUser user, string scope)
{
lock (_gate)
{
var roles = RolesOf(user).Where(r => r.Scope == PageCatalog.Wildcard
|| string.Equals(r.Scope, scope, StringComparison.OrdinalIgnoreCase)).ToList();
var scopeKeys = PageCatalog.KeysForScope(scope).ToHashSet(StringComparer.OrdinalIgnoreCase);
var pages = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var ops = new HashSet<string>(StringComparer.Ordinal);
var allOps = false;
var bestWidget = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var r in roles)
{
if (r.Pages.Contains(PageCatalog.Wildcard)) pages.UnionWith(scopeKeys);
else foreach (var p in r.Pages) if (scopeKeys.Contains(p)) pages.Add(p);
foreach (var o in r.Ops)
{
if (o == "*") allOps = true;
else ops.Add(o);
}
foreach (var g in r.WidgetGrants)
{
if (!bestWidget.TryGetValue(g.WidgetId, out var cur) || Rank(g.Visibility) > Rank(cur))
bestWidget[g.WidgetId] = g.Visibility;
}
}
return new EffectiveResult(
pages.OrderBy(p => p, StringComparer.Ordinal).ToList(),
allOps ? new List<string> { "*" } : ops.OrderBy(o => o, StringComparer.Ordinal).ToList(),
bestWidget.Select(kv => new WidgetGrantDto(kv.Key, kv.Value)).ToList());
}
}
// ───────────────────────── 管理端读取 ─────────────────────────
public List<RbacRole> ListRoles()
{
lock (_gate) { return _snapshot.Roles.Select(Clone).ToList(); }
}
public List<RbacUserDto> ListUsers()
{
lock (_gate) { return _snapshot.Users.Select(ToDto).ToList(); }
}
// ───────────────────────── 用户 CRUD ─────────────────────────
public RbacUserDto CreateUser(CreateUserRequest req)
{
if (string.IsNullOrWhiteSpace(req.Username)) throw new RbacException("用户名不能为空");
if (string.IsNullOrEmpty(req.Password)) throw new RbacException("初始密码不能为空");
lock (_gate)
{
if (FindByName(req.Username) is not null) throw new RbacException($"用户名 {req.Username} 已存在");
var roleIds = FilterExistingRoles(req.RoleIds);
var user = NewUser($"u-{NewId()}", req.Username.Trim(),
string.IsNullOrWhiteSpace(req.DisplayName) ? req.Username.Trim() : req.DisplayName!.Trim(),
req.Password, roleIds);
user.Enabled = req.Enabled;
_snapshot.Users.Add(user);
Persist();
return ToDto(user);
}
}
public RbacUserDto UpdateUser(string id, UpdateUserRequest req)
{
lock (_gate)
{
var u = _snapshot.Users.FirstOrDefault(x => x.Id == id) ?? throw new RbacException("用户不存在");
var oldName = u.DisplayName;
var oldRoles = u.RoleIds;
var oldEnabled = u.Enabled;
if (req.DisplayName is not null) u.DisplayName = req.DisplayName.Trim();
if (req.RoleIds is not null) u.RoleIds = FilterExistingRoles(req.RoleIds);
if (req.Enabled is bool en) u.Enabled = en;
// M5:若本次改动(改角色 / 停用)导致系统再无有效管理员,则回滚后报错。
try { EnsureAdminRemainsNoLock(); }
catch { u.DisplayName = oldName; u.RoleIds = oldRoles; u.Enabled = oldEnabled; throw; }
Persist();
return ToDto(u);
}
}
public void SetPassword(string id, string password)
{
if (string.IsNullOrEmpty(password)) throw new RbacException("密码不能为空");
lock (_gate)
{
var u = _snapshot.Users.FirstOrDefault(x => x.Id == id) ?? throw new RbacException("用户不存在");
var salt = RandomNumberGenerator.GetBytes(16);
u.Salt = Convert.ToBase64String(salt);
u.PasswordHash = Convert.ToBase64String(Pbkdf2(password, salt));
Persist();
}
}
public void DeleteUser(string id)
{
lock (_gate)
{
var u = _snapshot.Users.FirstOrDefault(x => x.Id == id) ?? throw new RbacException("用户不存在");
var idx = _snapshot.Users.IndexOf(u);
_snapshot.Users.RemoveAt(idx);
// M5:删除后若系统再无有效管理员,则恢复并报错。
try { EnsureAdminRemainsNoLock(); }
catch { _snapshot.Users.Insert(idx, u); throw; }
Persist();
}
}
// ───────────────────────── 角色 CRUD ─────────────────────────
public RbacRole CreateRole(SaveRoleRequest req)
{
if (string.IsNullOrWhiteSpace(req.Name)) throw new RbacException("角色名称不能为空");
var scope = NormalizeScope(req.Scope);
lock (_gate)
{
var role = new RbacRole
{
Id = $"role-{NewId()}",
Name = req.Name.Trim(),
Description = req.Description?.Trim() ?? "",
Scope = scope,
Pages = SanitizePages(req.Pages),
Ops = req.Ops?.Distinct().ToList() ?? new(),
WidgetGrants = req.WidgetGrants ?? new(),
System = false
};
_snapshot.Roles.Add(role);
Persist();
return Clone(role);
}
}
public RbacRole UpdateRole(string id, SaveRoleRequest req)
{
if (string.IsNullOrWhiteSpace(req.Name)) throw new RbacException("角色名称不能为空");
var scope = NormalizeScope(req.Scope);
lock (_gate)
{
var role = _snapshot.Roles.FirstOrDefault(r => r.Id == id) ?? throw new RbacException("角色不存在");
var backup = Clone(role);
role.Name = req.Name.Trim();
role.Description = req.Description?.Trim() ?? "";
role.Scope = scope;
role.Pages = SanitizePages(req.Pages);
role.Ops = req.Ops?.Distinct().ToList() ?? new();
role.WidgetGrants = req.WidgetGrants ?? new();
// M5:若本次改动(如去掉角色的 "*"/auth.manage)导致系统再无有效管理员,则回滚。
try { EnsureAdminRemainsNoLock(); }
catch
{
role.Name = backup.Name; role.Description = backup.Description; role.Scope = backup.Scope;
role.Pages = backup.Pages; role.Ops = backup.Ops; role.WidgetGrants = backup.WidgetGrants;
throw;
}
Persist();
return Clone(role);
}
}
public void DeleteRole(string id)
{
lock (_gate)
{
var role = _snapshot.Roles.FirstOrDefault(r => r.Id == id) ?? throw new RbacException("角色不存在");
if (role.System) throw new RbacException("内置系统角色不可删除");
var inUse = _snapshot.Users.Where(u => u.RoleIds.Contains(id)).Select(u => u.Username).ToList();
if (inUse.Count > 0)
throw new RbacException($"角色仍被 {inUse.Count} 个用户使用({string.Join(", ", inUse.Take(5))}{(inUse.Count > 5 ? "" : "")}),请先解除关联");
_snapshot.Roles.Remove(role);
Persist();
}
}
public bool RoleExists(string id)
{
lock (_gate) { return _snapshot.Roles.Any(r => r.Id == id); }
}
// ───────────────────────── 内部工具 ─────────────────────────
private RbacUser? FindByName(string username) =>
_snapshot.Users.FirstOrDefault(u => string.Equals(u.Username, username, StringComparison.OrdinalIgnoreCase));
private List<RbacRole> RolesOf(RbacUser user) =>
user.RoleIds.Select(id => _snapshot.Roles.FirstOrDefault(r => r.Id == id))
.Where(r => r is not null).Select(r => r!).ToList();
private const string OpAuthManage = "auth.manage";
/// <summary>
/// 该用户当前是否为「有效系统管理员」:启用 且 至少一个角色的 Ops 含 "*" 或 "auth.manage"。
/// 用于 M5「最后管理员」保护。
/// </summary>
private bool IsActiveAdminNoLock(RbacUser u)
{
if (!u.Enabled) return false;
foreach (var r in RolesOf(u))
if (r.Ops.Contains("*") || r.Ops.Contains(OpAuthManage)) return true;
return false;
}
private int CountActiveAdminsNoLock() => _snapshot.Users.Count(IsActiveAdminNoLock);
/// <summary>校验修改应用后系统仍至少有一名有效管理员,否则抛异常(调用方负责回滚内存改动)。</summary>
private void EnsureAdminRemainsNoLock()
{
if (CountActiveAdminsNoLock() == 0)
throw new RbacException("该操作会使系统再无任何具备管理权限(auth.manage)的启用账号,已阻止。请至少保留一名超级管理员。");
}
private List<string> FilterExistingRoles(List<string>? roleIds) =>
(roleIds ?? new()).Where(id => _snapshot.Roles.Any(r => r.Id == id)).Distinct().ToList();
private static List<string> SanitizePages(List<string>? pages)
{
if (pages is null) return new();
if (pages.Contains(PageCatalog.Wildcard)) return new() { PageCatalog.Wildcard };
return pages.Where(PageCatalog.IsValidKey).Distinct().ToList();
}
private static string NormalizeScope(string? scope) => scope switch
{
PageCatalog.ScopePlatform => PageCatalog.ScopePlatform,
PageCatalog.ScopeMonitor => PageCatalog.ScopeMonitor,
PageCatalog.Wildcard => PageCatalog.Wildcard,
_ => throw new RbacException($"无效 scope: {scope}(应为 Platform / RCSMonitor / *")
};
private static int Rank(string visibility) => visibility switch
{
"interactive" => 2,
"readonly" => 1,
_ => 0
};
private static RbacUser NewUser(string id, string username, string displayName, string password, List<string> roleIds)
{
var salt = RandomNumberGenerator.GetBytes(16);
return new RbacUser
{
Id = id,
Username = username,
DisplayName = displayName,
Enabled = true,
RoleIds = roleIds,
Salt = Convert.ToBase64String(salt),
PasswordHash = Convert.ToBase64String(Pbkdf2(password, salt))
};
}
private RbacUserDto ToDto(RbacUser u) =>
new(u.Id, u.Username, u.DisplayName, u.Enabled, new List<string>(u.RoleIds), UsableScopesNoLock(u));
private List<string> UsableScopesNoLock(RbacUser user)
{
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var r in RolesOf(user))
{
if (r.Scope == PageCatalog.Wildcard) { set.Add(PageCatalog.ScopePlatform); set.Add(PageCatalog.ScopeMonitor); }
else set.Add(r.Scope);
}
return set.ToList();
}
private static bool VerifyHash(string password, string saltB64, string hashB64)
{
try
{
var salt = Convert.FromBase64String(saltB64);
var expected = Convert.FromBase64String(hashB64);
var actual = Pbkdf2(password, salt);
return CryptographicOperations.FixedTimeEquals(actual, expected);
}
catch { return false; }
}
private static byte[] Pbkdf2(string password, byte[] salt) =>
Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), salt, iterations: 100_000, HashAlgorithmName.SHA256, 32);
private static string NewId() => Guid.NewGuid().ToString("N")[..8];
private static RbacUser Clone(RbacUser u) => new()
{
Id = u.Id, Username = u.Username, DisplayName = u.DisplayName, Enabled = u.Enabled,
RoleIds = new List<string>(u.RoleIds), Salt = u.Salt, PasswordHash = u.PasswordHash
};
private static RbacRole Clone(RbacRole r) => new()
{
Id = r.Id, Name = r.Name, Description = r.Description, Scope = r.Scope,
Pages = new List<string>(r.Pages), Ops = new List<string>(r.Ops),
WidgetGrants = r.WidgetGrants.Select(w => new WidgetGrantDto(w.WidgetId, w.Visibility)).ToList(),
System = r.System
};
}
/// <summary>RBAC 业务校验异常 —— 由 <c>RbacController</c> 统一翻译成 400 + message。</summary>
public sealed class RbacException : Exception
{
public RbacException(string message) : base(message) { }
}