Files
Migu2.0/MiGu.Server/Auth/RbacStore.cs
T

552 lines
23 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 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) { }
}