19 · 账号、角色与权限
相关:第 17 章 宿主、进程与壳 · 第 12 章 脚本运行时与脚本形态
这一章是什么
Hmi.Security 管一件事:谁能做什么。
什么时候你会需要它:
- 现场操作工只能看画面、能按按钮,但不能改工程
- 工程师能改画面、配配方,但不能建账号
- 某个按钮只有"组长"以上才能点
这个程序集有个特点:它不依赖任何别的 Hmi.* ——
可以单独摘出来放到你自己的项目里用。
一、三个概念
账号(UserAccount) ──属于──▶ 角色(Role) ──持有──▶ 权限(Permission 位)
"张三" "操作工" ViewRuntime | ControlWrite
| 概念 | 是什么 | 例子 |
|---|---|---|
| 账号 | 一个人(登录名 + 口令) | zhangsan / 张三 |
| 角色 | 一组权限的打包,带一个等级 | 操作工(等级 20) |
| 权限 | 最小粒度,"能不能做某件事" | AckAlarm(能确认报警) |
为什么不直接给账号配权限:人多的时候一个个配会疯。打包成角色,新人来了直接归到角色上。
二、8 个权限位
权限是位标志 —— 一个角色可以同时持有多项,用按位或拼起来。
| 权限 | 位 | 意思 |
|---|---|---|
ViewRuntime |
1<<0 | 看运行态画面(最基本,操作工都有) |
ControlWrite |
1<<1 | 运行态写点 / 下发 PLC —— ⚠ 有物理后果 |
AckAlarm |
1<<2 | 确认 / 处理报警 |
LoadRecipe |
1<<3 | 选择 / 下装配方 |
EditRecipe |
1<<4 | 编辑 / 新建配方 |
EditConfig |
1<<5 | 组态编辑(改画面/点表/通道/脚本) |
ManageUsers |
1<<6 | 用户与权限管理(建账号/改角色/改密码) |
SystemAdmin |
1<<7 | 系统设置(工程设置/多语言/数据库维护) |
All |
全 1 | 所有已定义位的并集 |
None |
0 | 无任何权限 |
⚠ 新增权限项要往后加一个 2 的幂,绝不能改旧值 —— 权限值会存进数据库, 改旧值等于把所有已有角色的权限悄悄改掉了。
⚠
All是"所有已定义位的并"。所以以后新增一个权限位,All会自动包含它。 这是刻意的(开发人员就该全权),但你要知道这个行为。
三、内置的四个角色
Role.Defaults 里写死了四个,初始化账号库时写入(用户之后可以再改):
| 角色 | 等级 | 有哪些权限 | 说明 |
|---|---|---|---|
| 开发人员 | 100 | All |
最高权限:组态 + 用户管理 + 系统设置 + 控制 |
| 工程师 | 80 | 看画面 + 控制 + 确认报警 + 配方(读+写) + 组态编辑 | 组态编辑 + 配方 + 控制,但不管账号 |
| 组长 | 50 | 看画面 + 控制 + 确认报警 + 下装配方 | 控制 + 确认报警 + 下装配方 |
| 操作工 | 20 | 看画面 + 控制 + 确认报警 | 看画面 + 基本操作 + 确认报警 |
⭐ "访客"不是一个角色
运行态未登录 = 访客态。画面照常看,需要权限的控件自动置灰。
所以:
- 不需要给"访客"建一个带口令的账号
- 只在"比看画面更多"的时候才建账号
- 想当访客?不登录就行
这个设计省掉了一类尴尬:如果访客是一个角色,那它也有账号密码 —— 但"所有人都能用的账号"等于没账号。
四、口令是怎么存的
PasswordHasher —— PBKDF2(HMAC-SHA256):
| 参数 | 值 |
|---|---|
| 盐长度 | 16 字节(每账号独立随机) |
| 哈希长度 | 32 字节 |
| 迭代次数 | 100,000 |
public static (string hash, string salt) Create(string password); // 注册/改密时用
public static bool Verify(string password, string hashBase64, string saltBase64);
几个设计要点:
| 做法 | 为什么 |
|---|---|
| 不可逆 | 库里泄露也拿不到明文口令 |
| 每账号独立盐 | 两个人口令一样,哈希也不一样;彩虹表失效 |
| 10 万次迭代 | 暴力破解成本极高(每次尝试都要算 10 万轮) |
定时恒定比对(FixedTimeEquals) |
防时序侧信道 —— 逐字节比会在第一个不同字节处提前返回,攻击者能靠耗时猜出前缀 |
Verify 出错返回 false 不抛 |
库里哈希损坏时不能把程序带崩 |
定时恒定比对是什么:普通字符串比较
a == b会在发现第一个不同字符时就返回。 攻击者反复试,能根据"耗时长短"推断出"前几个字符猜对了"。FixedTimeEquals无论如何都跑完全程。
五、AuthService —— 主入口
public AuthService(IUserStore store, bool configMode = false);
5.1 ⭐ configMode:组态模式 vs 运行态
这是最需要理解的一个开关:
configMode = true(组态模式) |
configMode = false(运行态) |
|
|---|---|---|
| 谁用 | 编辑器进程 | 运行态壳(--runtime) |
Can(...) |
一律放行 | 真查权限 |
CurrentLevel |
视作最高级 | 查实际角色等级 |
| 为什么 | 开发者本来就握有工程文件的完全访问权,再加一道登录门没有安全收益,只是碍事 | 操作工用,登录 + 权限 + 等级约束一个都不能少 |
⚠ 只应由编辑器进程构造时传
true。 好在两者是不同进程(运行态壳以--runtime独立起,自己的AuthService另行构造为false),不会串。
5.2 登录与登出
public UserAccount? Current { get; } // 当前登录用户,未登录为 null
public Role? CurrentRole { get; } // 当前用户的角色
public string CurrentUserName { get; } // 登录名(脚本 Sys.CurrentUser 用它)
public int CurrentLevel { get; } // 当前用户等级,未登录为 -1
public AuthResult Login(string username, string password);
public void Logout();
登录失败的三种回话:
| 情况 | 回什么 |
|---|---|
| 用户不存在 / 口令错 | "用户名或密码错误。" ← 刻意不区分,防账号枚举 |
| 账号已停用 | "账号已停用。" |
| 用户名为空 | "请输入用户名。" |
为什么不区分"用户不存在"和"密码错":如果分开了,攻击者可以拿一份用户名字典 逐个试,先筛出"哪些用户名是存在的",再集中攻破。统一回话就没这个信息了。
5.3 权限判定
public bool Can(Permission permission); // 有没有这个权限
public AuthResult Demand(Permission permission); // 没有就回失败(后端"卡一刀"用)
Demand 的失败信息:
- 未登录 →
"未登录,操作被拒。" - 有登录但权限不够 →
"权限不足:需要 XXX。"
判据是"位是否全含":
(CurrentRole.Permissions & permission) == permission
注意是
== permission,不是!= 0。传一个组合(比如AckAlarm | ControlWrite)时, 两个都要有才算通过。
5.4 首次引导:建第一个管理员
public bool StoreHasAnyUser { get; } // 库里有没有账号
public AuthResult Bootstrap(string username, string password,
string roleName = "开发人员", string displayName = "");
先看 StoreHasAnyUser:
false→ 界面弹"创建并登录"引导(而不是登录框)true→ 弹登录框
Bootstrap 的约束:
- 只在库里一个账号都没有时可用;已有账号则拒绝
- 用户名/口令不能空;角色必须存在
⭐
Bootstrap成功后会直接置为已登录状态 —— 这一点很关键。 界面上这个按钮就叫「创建并登录」。如果不置会话,前端拿到的会话是 null, 顶栏没有当前用户、所有权限判定为 false,人会卡在登录页反复引导。
六、账号管理
6.1 ⭐ 等级约束:只能碰比自己低的
这一条贯穿所有账号管理操作:
| 操作 | 约束 |
|---|---|
CreateUser |
目标角色等级必须 < 自己 |
ChangePassword(改别人) |
目标等级 < 自己 |
SetDisabled |
目标等级 < 自己,且不能停用自己 |
SetUserRole |
旧角色和新角色都必须 < 自己,且不能改自己的角色 |
DeleteUser |
目标等级 < 自己,且不能删自己 |
SaveRole |
角色等级必须 < 自己 |
DeleteRole |
角色等级必须 < 自己 |
为什么一刀切:这一条同时挡掉了两种越权:
- 低等级管理员给自己加权限位(提权)
- 把自己所属角色的
ManageUsers摘掉(自锁 —— 之后谁也管不了账号了)
副作用:最高角色(开发人员,等级 100)永远改不动。 这是故意的 —— 它就是"兜底全权",动不了才不会出现"没人能管账号"的死局。
6.2 建账号
public AuthResult CreateUser(string username, string password,
string roleName, string displayName = "");
要求:ManageUsers 权限 + 目标角色等级 < 自己 + 用户名不重复 + 角色存在。
6.3 改口令
public AuthResult ChangePassword(string username, string newPassword, string? oldPassword = null);
两条不同的路径:
| 改谁的 | 要求 |
|---|---|
| 改自己 | 必须提供 oldPassword 并验证通过 |
| 改别人 | 需要 ManageUsers,且目标等级 < 自己(不用知道旧口令) |
所以管理员能帮人重置口令,但改自己必须验旧口令 —— 防止有人趁你离开电脑改你的密码。
6.4 停用 vs 删除
SetDisabled(停用) |
DeleteUser(删除) |
|
|---|---|---|
| 效果 | 不能登录,账号还在 | 从库里彻底移除 |
| 可逆 | 可以再启用 | 不可逆 |
| 用在哪 | "暂时不让这个人登录" | "这个人离职了" |
两个都给,由使用方按语义选。
6.5 角色管理
public AuthResult SaveRole(Role role); // 新建或修改(按名覆盖)
public AuthResult DeleteRole(string name);
两个约束:
| 约束 | 为什么 |
|---|---|
| 角色名是主键,不支持改名 | 要改名 = 新建一个 → 把账号逐个 SetUserRole 过去 → 删旧的 |
| 仍有账号在用则拒绝删除 | 否则那些账号会指向不存在的角色,权限判定静默变成"零权限" |
权限位合法性校验:
if ((role.Permissions & ~Permission.All) != Permission.None)
return AuthResult.Fail($"含未定义的权限位:{(long)(...)}。");
这是防"手改 JSON 塞一个
1<<40"这种脏位。只认已定义的位。
七、账号库存在哪
IUserStore 是存储抽象,SqliteUserStore 是 SQLite 实现:
public interface IUserStore
{
// 角色
IReadOnlyList<Role> GetRoles();
Role? GetRole(string name);
void UpsertRole(Role role);
bool DeleteRole(string name);
// 账号
IReadOnlyList<UserAccount> GetUsers();
UserAccount? GetUser(string username);
void UpsertUser(UserAccount user);
bool DeleteUser(string username);
}
public SqliteUserStore(string dbPath); // 传 ":memory:" 就是内存库(测试用,进程退出即失)
两张表:roles 和 users。
抽出接口的用意:测试时塞一个内存假件,不用起数据库;将来换库也不用改
AuthService。 注意IUserStore只做存取,不做口令校验和权限判定 —— 那些是AuthService的事。
UserAccount 的字段
| 字段 | 说明 |
|---|---|
Username |
登录名(唯一,大小写不敏感) |
DisplayName |
显示名(中文全名) |
RoleName |
所属角色名 |
PasswordHash |
PBKDF2 哈希(Base64) |
PasswordSalt |
每账号随机盐(Base64) |
Disabled |
是否停用 |
CreatedUtc |
创建时间(UTC) |
八、⭐ 别把 UserAccount 整个发出去
AllUsers() 返回的 UserAccount 带 PasswordHash 和 PasswordSalt。
⚠ 调用方必须挑白名单字段再序列化。整个对象发出去 = 把口令哈希送到前端。 契约 07 明令禁止。
登录器为什么用另一个类型
画面上那个「用户登录」控件要在登录前把账号列出来给人选。这个清单:
public IReadOnlyList<LoginCandidate> LoginCandidates(); // 未登录可调
返回的是 LoginCandidate,只有三个字段:
| 字段 | 说明 |
|---|---|
Username |
登录名 |
DisplayName |
显示名 |
Disabled |
是否停用(停用账号照样列出,界面上置灰,登录时才拒) |
刻意不含角色名与等级。
⭐ 为什么单独立一个类型,而不是"记得挑字段":
LoginCandidate里根本没有哈希和盐这两个字段 —— 所以"泄漏口令哈希"这件事在结构上就不可能发生,不靠纪律。不透露角色/等级,是因为未登录的人不该知道"谁是管理员"(防挑目标)。
已知取舍:未登录能列账号名 = 能枚举账号。这是功能本身带来的(要给人选就得给名字), 不是疏忽。缓解:不透露角色等级、不透露口令信息。面向的是车间内网触摸屏,账号名多为岗位名。
⚠ 别把
LoginCandidates()改成返回UserAccount,也别在这里加Demand(ManageUsers)—— 加了之后登录器就永远列不出账号(它调用的那一刻必然还没登录)。
九、前端能调的 RPC
| 方法名 | 干什么 |
|---|---|
auth_status |
查登录状态 + 库里有没有账号 |
auth_bootstrap |
建首个管理员(建完即登录) |
auth_login |
登录 |
auth_logout |
登出 |
auth_can |
查某个权限有没有 |
list_login_users |
列登录器候选(未登录可调) |
list_users |
列全部账号 |
create_user |
建账号 |
set_user_role |
改账号角色 |
change_password |
改口令 |
set_user_disabled |
停用/启用 |
delete_user |
删账号 |
list_roles |
列角色 |
save_role |
新建/改角色 |
delete_role |
删角色 |
list_users返回时必须投影成 DTO —— 不能直接把UserAccount序列化出去(见第八节)。
十、一个完整例子
using Hmi.Security;
var store = new SqliteUserStore(@"D:\工程\users.db");
var auth = new AuthService(store); // 运行态:configMode = false
// ① 第一次用:库里没账号 → 引导建管理员
if (!auth.StoreHasAnyUser)
{
var r = auth.Bootstrap("admin", "初始口令", "开发人员", "系统管理员");
Console.WriteLine(r.Message); // "已创建首个管理员 admin"
// 注意:这里已经自动登录了
}
// ② 建一个操作工
var r2 = auth.CreateUser("op01", "123456", "操作工", "一号机操作员");
Console.WriteLine(r2.Message); // "已创建账号 op01"
// ③ 换个人登录
auth.Logout();
auth.Login("op01", "123456");
Console.WriteLine($"当前用户:{auth.CurrentUserName},等级 {auth.CurrentLevel}");
// ④ 他能干什么?
Console.WriteLine(auth.Can(Permission.ViewRuntime)); // true
Console.WriteLine(auth.Can(Permission.AckAlarm)); // true
Console.WriteLine(auth.Can(Permission.EditConfig)); // false ← 操作工不能改组态
// ⑤ 试着改组态 → 被拦
var denied = auth.Demand(Permission.EditConfig);
Console.WriteLine(denied.Message); // "权限不足:需要 EditConfig。"
// ⑥ 操作工想建账号 → 也被拦(等级也不够)
var r3 = auth.CreateUser("hacker", "x", "开发人员");
Console.WriteLine(r3.Message); // "权限不足:需要 ManageUsers。"
十一、这一章的坑
| # | 坑 | 怎么避 |
|---|---|---|
| 1 | 把 UserAccount 整个序列化发前端 |
会泄露口令哈希。挑白名单字段,或用 LoginCandidate |
| 2 | 给 LoginCandidates() 加权限判定 |
加了登录器就永远列不出账号 |
| 3 | 编辑器进程传 configMode = false |
开发者会被自己的权限挡住,寸步难行 |
| 4 | 运行态传 configMode = true |
⚠ 严重:操作工能改一切。绝不能这么干 |
| 5 | 改已有权限位的数值 | 权限值存库,改旧值会静默改掉所有角色的权限 |
| 6 | 指望 Bootstrap 能重复调 |
只在零账号时可用 |
| 7 | 删角色前没把账号转走 | 会拒绝。先 SetUserRole 转走再删 |
| 8 | 以为管理员能改开发人员的口令 | 等级约束:只能碰比自己低的 |
| 9 | 忘记 Bootstrap 会自动登录 |
不要再调一次 Login |
| 10 | Can 传组合权限时以为"有其一即可" |
是"全含"才算通过 |
本章内容以
src/Hmi.Security/为准。这个程序集不依赖任何Hmi.*,可以单独摘出来用。