用户认证与权限管理
这是「从零搭建灌装监控系统」系列第20篇。谁都能改工艺参数,这件事迟早会出事。这篇实现密码哈希、三级角色和登录窗口,并处理两个容易做错的点:固定盐为什么不安全,以及密码框为什么天生不能绑定。

操作员把工艺参数改了
现场调试的时候,我给了操作员一个账号,本意只是让他看数据。
第二天工艺工程师找过来,说配方里的加热温度被人从 180 改成 175,找不出是谁改的。查了半天,只能确定“不是他改的”,因为软件里没有任何记录——大家用的是同一个账号。
问题有两层。第一层是没有区分身份,所有人共用一个登录态;第二层是没有记录动作,改了什么、谁改的,系统不知道。
这篇先解决第一层。
密码不能明文存
第一版我图省事,Users 表里存的是明文密码。测试的时候方便,直接查数据库就能看到 admin 的密码。
这个做法在任何真实项目里都不能留。数据库文件在客户手里,现场随便一个能访问文件的人都能拿到全部密码。而且人会在多个系统用同一个密码——泄露一个等于泄露一片。
所以要做哈希。项目用的是 SHA256 加固定盐:
public static class UserService
{
// 用于密码加盐的固定字符串
private const string StaticSalt = "your-static-salt-here";
private static string HashPassword(string password)
{
if (string.IsNullOrEmpty(password))
return string.Empty;
using var sha = SHA256.Create();
var raw = Encoding.UTF8.GetBytes(password + StaticSalt);
var bytes = sha.ComputeHash(raw);
return Convert.ToHexString(bytes).ToLowerInvariant();
}
}
哈希是单向的:数据库里存的是 64 位十六进制串,攻击者拿到它也没法直接反推出密码。
加盐的作用是防彩虹表。不加盐的话,admin 的哈希值在所有用 SHA256 的系统里都一样,攻击者查一张预先算好的表就能还原。加了盐之后,哈希值跟这个系统绑定,通用表失效。
但是固定盐有个致命问题
固定盐能防彩虹表,却防不了同一系统内的横向对比。
假设两个人都用 123456 做密码。因为盐是固定的,他们俩的哈希值完全相同。攻击者只要看到两条相同的哈希,就知道这两个人密码一样;更进一步,只要猜出其中一个人的密码,另一个也一起破了。
更实际的风险是:盐写死在代码里,一旦反编译(.NET 程序反编译成本很低),盐就暴露了,防彩虹表的作用也打折。
正确的做法是每个用户一个随机盐,并且用专门为密码设计的慢哈希算法:
// 更稳妥的做法(示意)
private static (string hash, string salt) CreateHash(string password)
{
// 16 字节随机盐,每个用户独立
var salt = RandomNumberGenerator.GetBytes(16);
// PBKDF2:迭代次数让每次哈希变慢,暴力破解成本大幅上升
var hash = Rfc2898DeriveBytes.Pbkdf2(
password, salt,
iterations: 210_000,
HashAlgorithmName.SHA256,
outputLength: 32);
return (Convert.ToBase64String(hash), Convert.ToBase64String(salt));
}
两个关键差异:
盐存在用户记录里,每个用户不同。 数据库里同时存 PasswordHash 和 Salt,验证时取出该用户的盐重算。
用慢哈希而不是 SHA256。 SHA256 的设计目标是“快”——一块普通显卡每秒能算几十亿次。这对校验文件是优点,对存密码是灾难。PBKDF2、bcrypt、Argon2 故意设计得慢(十万次以上迭代),让暴力枚举变得不现实。
文章里的项目用的是固定盐 SHA256,属于“比明文好,但不够好”。这里如实写出来,是因为很多人第一次做认证就是这么写的——知道它哪里不够,比假装它没问题更有用。
三级角色
角色用枚举定义,用 Description 标注中文名,界面可以直接读:
public enum Role
{
[Description("管理员")]
Admin = 0,
[Description("工程师")]
Engineer = 1,
[Description("操作员")]
Operator = 2,
}
三个角色的权限边界:
| 角色 | 看数据 | 改工艺参数 | 改系统设置 |
|---|---|---|---|
| 操作员 | ✅ | ❌ | ❌ |
| 工程师 | ✅ | ✅ | ❌ |
| 管理员 | ✅ | ✅ | ✅ |
用 Description 而不是在界面里写 if (role == Role.Admin) return "管理员",是为了让中文名只有一处定义。界面、日志、导出都从同一个地方取。
首次启动创建默认账户
软件第一次运行的时候数据库是空的,得有个能登录的账号:
public static async Task InitializeAsync()
{
try
{
bool hasUsers = await DbProvider.Fsql.Select<User>().AnyAsync();
if (!hasUsers)
{
var now = DateTime.Now;
var users = new List<User>
{
new User
{
UserName = "admin",
PasswordHash = HashPassword("admin"),
Role = Role.Admin,
CreatedAt = now,
},
new User
{
UserName = "engineer",
PasswordHash = HashPassword("engineer"),
Role = Role.Engineer,
CreatedAt = now,
},
};
var affrows = await DbProvider.Fsql.Insert(users).ExecuteAffrowsAsync();
if (affrows == 2)
LogService.Info("系统初始化:创建默认用户成功");
else
throw new Exception("创建失败");
}
}
catch (Exception ex)
{
LogService.Error("系统初始化用户失败", ex);
}
}
AnyAsync() 做前置判断,所以这段代码可以重复调用——只有库里一条用户都没有时才创建。这个性质很重要,它意味着初始化可以放在启动流程的固定位置,不用担心重复执行。
默认账号密码(admin/admin)是教学和演示用的。真实交付前必须改,或者在首次登录时强制要求修改密码。
登录态和事件
登录成功之后要把当前用户存下来,并且让界面知道状态变了:
private static User? _currentUser;
public static event Action<User?>? LoginStateChanged;
public static User? CurrentUser
{
get => _currentUser;
private set
{
if (_currentUser != value)
{
_currentUser = value;
LoginStateChanged?.Invoke(_currentUser);
}
}
}
set 是 private 的——外部只能读,改状态必须走登录/登出流程。事件在值真正变化时才发(if 判断),避免重复通知。
界面订阅这个事件来刷新权限相关的可见性:
UserService.LoginStateChanged += OnLoginStateChanged;
private void OnLoginStateChanged(User? user)
{
Application.Current.Dispatcher.Invoke(() =>
{
IsAdmin = user?.Role == Role.Admin;
IsEngineer = user?.Role is Role.Admin or Role.Engineer;
CurrentUserName = user?.UserName ?? "";
});
}
登录窗口的三个细节
登录窗口本身没什么技术含量,但有几个细节决定了它好不好用。
用户名用下拉,不用输入框
private async Task LoadUserAsync()
{
try
{
List<User> users = await UserService.GetAllUsersAsync();
UserNameCombo.ItemsSource = users;
if (users != null && users.Count > 0)
UserNameCombo.SelectedIndex = 0;
}
catch (Exception ex)
{
LogService.Error("加载用户列表失败", ex);
}
}
现场设备就那么几个账号,下拉选择比手打快,也不会打错。
回车登录、Esc 取消
private void LoginWindow_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Enter)
{
Login_Click(this, new RoutedEventArgs());
}
else if (e.Key == Key.Escape)
{
Cancel_Click(this, new RoutedEventArgs());
e.Handled = true;
}
}
操作员戴着手套用触屏,或者用键盘,回车登录是最自然的操作。e.Handled = true 防止 Esc 继续冒泡触发别的行为。
提交时锁住界面
IsEnabled = false; // 简单锁定 UI,防止重复点击操作
try
{
var ok = await UserService.AuthenticateAsync(username, password);
if (ok)
{
DialogResult = true;
Close();
}
else
{
MessageBox.Show("用户名或密码错误", "登录失败",
MessageBoxButton.OK, MessageBoxImage.Information);
PasswordBox.Clear();
PasswordBox.Focus();
}
}
finally { IsEnabled = true; }
认证要走数据库,是异步的。这中间用户完全可能再点一次“登录”。IsEnabled = false 是最简单的防重入,finally 保证无论成败都恢复。
注意失败提示统一写“用户名或密码错误”。 不要区分“用户不存在”和“密码错误”——那等于告诉攻击者哪些用户名是有效的。
PasswordBox 为什么不能绑定
做 MVVM 的人都会撞上这件事:TextBox 能 Text="{Binding Password}",PasswordBox 不行。
不是 WPF 忘了实现,是故意的。
如果密码能绑定到 ViewModel 的属性,那这个字符串就会长期存在于托管堆里。GC 移动对象时会在内存里留下副本,转储文件、日志、异常快照都可能把它带出去。PasswordBox 把密码放在非托管的安全内存里,并且提供 SecurePassword(返回 SecureString),就是为了缩短密码在托管堆里的存活时间。
所以正确做法是不绑定,在代码后置里读:
var password = PasswordBox.Password ?? string.Empty;
代价是 ViewModel 拿不到密码,登录逻辑得放在 View 的 code-behind(或者用附加属性把密码“推”过去,但那会重新引入内存问题)。这是安全性换来的合理妥协。
数据流:
踩坑记录
存明文密码
数据库文件在客户手里,等于密码公开。任何情况下都不行。
固定盐
同密码同哈希,横向对比就能批量破。盐要每个用户随机,存在用户记录里。
用 SHA256 存密码
SHA256 追求快,正好帮了暴力破解。密码要用慢哈希(PBKDF2 / bcrypt / Argon2)。
登录失败提示区分“用户不存在”和“密码错误”
等于给攻击者做用户名枚举。统一提示。
异步认证期间不锁界面
用户连点两次登录会发两次请求。IsEnabled = false + finally 恢复。
本篇小结
| 问题 | 做法 |
|---|---|
| 密码存储 | 哈希 + 盐,绝不存明文 |
| 盐的选择 | 每用户随机盐,存进用户记录 |
| 哈希算法 | 慢哈希(PBKDF2/bcrypt/Argon2) |
| 角色 | 枚举 + Description 统一中文名 |
| 默认账号 | 首次启动创建,可重复调用 |
| 登录态 | 私有 setter + 变更事件 |
| 密码输入 | PasswordBox 不绑定,code-behind 读取 |
功能齐了,测试有了,权限也有了。最后一步是把这一堆东西变成一个能拷到现场机器上、双击就能跑的包。这是这个系列的最后一篇。
下期预告
第21篇:项目打包与部署(完结篇)
最后一篇把整个系统打成部署包:发布配置、自包含运行时、单文件的取舍,以及第一次启动要自动做完哪些初始化。顺带对整个系列做一次收尾。
转载自 CSDN-专业IT技术社区




