samble头像
从零搭建灌装监控系统(二十):用户认证与权限管理封面图

从零搭建灌装监控系统(二十):用户认证与权限管理

用户认证与权限管理

这是「从零搭建灌装监控系统」系列第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(或者用附加属性把密码“推”过去,但那会重新引入内存问题)。这是安全性换来的合理妥协。

数据流:

是

是

否

登录窗口加载

读用户列表填下拉

用户选账号 + 输密码

回车?

IsEnabled = false 锁界面

AuthenticateAsync 查库

密码哈希匹配?

设置 CurrentUser

触发 LoginStateChanged

界面按角色刷新可见性

统一错误提示 + 清空密码


踩坑记录

存明文密码

数据库文件在客户手里,等于密码公开。任何情况下都不行。

固定盐

同密码同哈希,横向对比就能批量破。盐要每个用户随机,存在用户记录里。

用 SHA256 存密码

SHA256 追求快,正好帮了暴力破解。密码要用慢哈希(PBKDF2 / bcrypt / Argon2)。

登录失败提示区分“用户不存在”和“密码错误”

等于给攻击者做用户名枚举。统一提示。

异步认证期间不锁界面

用户连点两次登录会发两次请求。IsEnabled = false + finally 恢复。


本篇小结

问题做法
密码存储哈希 + 盐,绝不存明文
盐的选择每用户随机盐,存进用户记录
哈希算法慢哈希(PBKDF2/bcrypt/Argon2)
角色枚举 + Description 统一中文名
默认账号首次启动创建,可重复调用
登录态私有 setter + 变更事件
密码输入PasswordBox 不绑定,code-behind 读取

功能齐了,测试有了,权限也有了。最后一步是把这一堆东西变成一个能拷到现场机器上、双击就能跑的包。这是这个系列的最后一篇。


下期预告

第21篇:项目打包与部署(完结篇)

最后一篇把整个系统打成部署包:发布配置、自包含运行时、单文件的取舍,以及第一次启动要自动做完哪些初始化。顺带对整个系列做一次收尾。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/samble/article/details/166645318

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--