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 决定是否注册停机清理钩子
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
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),
|
||||
new("admin-playback", "调度回放", "概览", ScopePlatform),
|
||||
|
||||
// ── 管理端 / Platform:设计与编排 ──
|
||||
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-missions", "任务编排", "设计与编排", ScopePlatform),
|
||||
|
||||
// ── 管理端 / Platform:平台配置中心 ──
|
||||
new("admin-config-system", "系统级配置", "平台配置中心", ScopePlatform),
|
||||
new("admin-config-integrations", "外部系统对接", "平台配置中心", ScopePlatform),
|
||||
new("admin-config-routing", "路径规划", "平台配置中心", ScopePlatform),
|
||||
new("admin-config-vehicle", "车辆维护", "平台配置中心", ScopePlatform),
|
||||
new("admin-config-charge", "充电策略", "平台配置中心", ScopePlatform),
|
||||
new("admin-config-task", "任务分配", "平台配置中心", ScopePlatform),
|
||||
new("admin-config-traffic", "交通管制", "平台配置中心", ScopePlatform),
|
||||
new("admin-config-auth", "权限与角色", "平台配置中心", ScopePlatform),
|
||||
new("admin-config-device", "设备接入", "平台配置中心", ScopePlatform),
|
||||
new("admin-config-fleet", "车队生命周期", "平台配置中心", ScopePlatform),
|
||||
new("admin-config-scenario", "场景模板", "平台配置中心", ScopePlatform),
|
||||
new("admin-config-location", "库位管理", "平台配置中心", ScopePlatform),
|
||||
new("admin-config-ops", "运营维护", "平台配置中心", ScopePlatform),
|
||||
new("admin-config-widget", "自定义控件", "平台配置中心", ScopePlatform),
|
||||
new("admin-config-map-monitor", "地图监控配置", "平台配置中心", ScopePlatform),
|
||||
|
||||
// ── 运营端 / RCSMonitor ──
|
||||
new("monitor-dashboard", "运营总览", "运营监控", 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 是否合法(用于角色保存时过滤掉脏数据 / 已下线页面)。</summary>
|
||||
public static bool IsValidKey(string key) => _keys.Contains(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();
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
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);
|
||||
@@ -0,0 +1,481 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using MiGu.Server.Configs;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
_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()).Where(p => p == PageCatalog.Wildcard || PageCatalog.IsValidKey(p)).Distinct().ToList();
|
||||
r.Ops = (r.Ops ?? new()).Distinct().ToList();
|
||||
r.WidgetGrants ??= new();
|
||||
if (string.IsNullOrWhiteSpace(r.Scope)) r.Scope = PageCatalog.ScopePlatform;
|
||||
}
|
||||
foreach (var u in snap.Users)
|
||||
{
|
||||
u.RoleIds = (u.RoleIds ?? new()).Distinct().ToList();
|
||||
}
|
||||
return snap;
|
||||
}
|
||||
|
||||
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-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
|
||||
{
|
||||
File.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("用户不存在");
|
||||
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;
|
||||
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("用户不存在");
|
||||
_snapshot.Users.Remove(u);
|
||||
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("角色不存在");
|
||||
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();
|
||||
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 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) { }
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace MiGu.Server.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// 最简化的用户表(占位实现):内置 admin / ops 两个账号 + PBKDF2 哈希密码校验。
|
||||
/// 真实生产应替换成 Microsoft.AspNetCore.Identity 或外接 LDAP / OAuth。
|
||||
///
|
||||
/// 安全要点(哪怕是占位也要做到):
|
||||
/// - 密码不明文存储,启动期用 PBKDF2-SHA256(100k iter, 16B salt) 哈希;
|
||||
/// - 密码 hash 比较走 <see cref="CryptographicOperations.FixedTimeEquals"/> 防时序攻击;
|
||||
/// - 不允许「空用户名 = 空密码」之类的快捷绕过。
|
||||
///
|
||||
/// 默认账号:
|
||||
/// admin / admin (Platform scope, role-admin)
|
||||
/// ops / ops (RCSMonitor scope, role-ops)
|
||||
/// 默认密码同名是为了**开发机一次启动就能登录**;生产部署务必通过环境变量
|
||||
/// <c>PLATFORM__AUTH__USERS__<USERNAME>__PASSWORD</c> 改写或接入真实身份源。
|
||||
/// </summary>
|
||||
public sealed class UserStore
|
||||
{
|
||||
public sealed record UserRecord(
|
||||
string Id,
|
||||
string Username,
|
||||
string DisplayName,
|
||||
string DefaultScope,
|
||||
IReadOnlyList<string> Roles,
|
||||
byte[] Salt,
|
||||
byte[] PasswordHash);
|
||||
|
||||
private readonly Dictionary<string, UserRecord> _users;
|
||||
|
||||
public UserStore(IConfiguration config, ILogger<UserStore> logger)
|
||||
{
|
||||
_users = new Dictionary<string, UserRecord>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// 1. 内置 admin / ops(密码可从 appsettings 覆盖)
|
||||
var adminPwd = config["Auth:Users:admin:Password"] ?? "admin";
|
||||
var opsPwd = config["Auth:Users:ops:Password"] ?? "ops";
|
||||
|
||||
Add("u-admin", "admin", "系统管理员", "Platform", new[] { "role-admin", "role-platform-write" }, adminPwd);
|
||||
Add("u-ops", "ops", "运营人员", "RCSMonitor", new[] { "role-ops", "role-monitor-read" }, opsPwd);
|
||||
|
||||
if (adminPwd == "admin" || opsPwd == "ops")
|
||||
{
|
||||
logger.LogWarning(
|
||||
"UserStore 使用默认弱密码(admin/admin 或 ops/ops)。生产环境务必通过 appsettings.Production.json " +
|
||||
"或环境变量 PLATFORM__AUTH__USERS__admin__PASSWORD 等覆盖。");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>用户名 / 密码校验。返回 null = 不存在或密码错。<b>不向调用方区分两种失败原因</b>,防用户名枚举。</summary>
|
||||
public UserRecord? Verify(string username, string password)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrEmpty(password)) return null;
|
||||
if (!_users.TryGetValue(username, out var u)) return null;
|
||||
|
||||
var hash = Pbkdf2(password, u.Salt);
|
||||
return CryptographicOperations.FixedTimeEquals(hash, u.PasswordHash) ? u : null;
|
||||
}
|
||||
|
||||
public UserRecord? Find(string username) =>
|
||||
_users.TryGetValue(username ?? "", out var u) ? u : null;
|
||||
|
||||
private void Add(string id, string username, string displayName, string defaultScope, IReadOnlyList<string> roles, string plainPassword)
|
||||
{
|
||||
var salt = RandomNumberGenerator.GetBytes(16);
|
||||
var hash = Pbkdf2(plainPassword, salt);
|
||||
_users[username] = new UserRecord(id, username, displayName, defaultScope, roles, salt, hash);
|
||||
}
|
||||
|
||||
private static byte[] Pbkdf2(string password, byte[] salt) =>
|
||||
Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), salt, iterations: 100_000, HashAlgorithmName.SHA256, 32);
|
||||
}
|
||||
Reference in New Issue
Block a user