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 角色等级必须 < 自己

为什么一刀切:这一条同时挡掉了两种越权:

  1. 低等级管理员给自己加权限位(提权)
  2. 把自己所属角色的 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.*,可以单独摘出来用。