🪟 第19章 C#与SQL Server实现企业人事管理系统
📄 一句话概括

登录四步里,顺序不能换:先查用户、再查锁定、再比密码、最后进主窗体。 密码不能存明文,也不能只存 MD5——MD5 的问题不是能被反推,是算得太快。 「锁 5 分钟」而不是永久锁,是因为永久锁会被人拿来锁死别人的号。

📝 本章和前面几章的关系

第 18 章的登录只讲了「查一行、比一下」。这一章的登录要讲透三件事: 密码怎么存、失败怎么计、账号怎么锁。 教材 19.5 分三小节:登录模块概述、技术分析、实现过程。 「技术分析」这一节是全章唯一讲密码学的地方,虽然只有两页,但比前面十七章都硬。 沙箱没有 SQL Server 的 HASHBYTES,所以密码这一节用 Python 的 hashlib 真跑, T-SQL 的 HASHBYTES('SHA2_256', ...) 照贴标不执行——算法一样,只是调用方式不同。

一、四步校验,顺序不能换

步 做什么 失败怎么办
1 按用户名查一行 统一提示「用户名或密码错」
2 查是否被锁定 提示「已锁定,稍后再试」
3 用存的盐重算密码比对 失败次数 +1
4 写登录日志、进主窗体 按角色决定菜单

第 1 步失败时提示为什么要「统一」? 因为分开提示等于告诉对方「这个用户名存在」,这就是给撞库的人送情报。 第 2 步必须在第 3 步之前。 顺序反了,锁就没用了—— 攻击者照样能一直试密码,只是每次都被判「密码错」,锁定形同虚设。 这一条在代码里特别容易写反,因为「先比密码再管锁定」看起来更自然。 第 4 步「写登录日志」经常被砍掉。 建议别砍—— 出问题时,「谁在什么时候从哪台机器登进来」是唯一能查的东西。 日志不写密码,只写用户名、时间、成功与否。

二、密码存储:从明文到加盐慢哈希

存法 被拖库之后 评价
明文 直接看到所有人的密码 绝对不能
MD5 一秒几十亿次,八位密码几小时撞完 等于没存
MD5 + 盐 彩虹表失效,但单条还是几小时 挡一半
PBKDF2 十万轮 同样的字典要算几十年 当前推荐

MD5 的问题不是「能被反推」,是「算得太快」。 一张显卡一秒能算几十亿次 MD5——所以任何八位密码几小时就能撞出来。 PBKDF2 十万轮把这个数字压到「一秒几百次」。 同样的字典要算几十年—— 慢,就是它的功能。 这不是效率低,这是设计目标。 盐解决的是另一件事:两个人密码一样,哈希值不能一样。 不然攻破一张表,等于攻破了所有用同一张彩虹表的系统。 盐必须每人一个、随机生成、跟哈希存在一起(盐不用保密)。

三、真跑一遍:正确密码也会登不进去

第几次 输入 结果
1 正确密码 成功
2 错的 失败,第 1 次
3 错的 失败,第 2 次
4 错的 失败,第 3 次 → 锁定 5 分钟
5 正确密码 失败,账号已锁定

第 5 行最值得看:密码是对的,但还是失败。 因为第 4 次失败已经把它锁了——这一行就是「锁定检查在密码校验之前」的证据。 如果顺序写反了,第 5 行会显示「成功」,锁定就永远不会生效。 测试这件事只需要多打一行,但很多人想不到要测它。 锁定的时长是个权衡。 锁 5 分钟:攻击者一小时只能试 12 次; 永久锁:任何人都能拿别人的用户名把你锁死。 标准答案通常是「短期锁 + 通知本人 + 允许管理员解锁」。

四、比对要用恒定时间函数

写法 行为 风险
a == b 第一个不同就返回 耗时泄露「错了第几位」
compare_digest 按位全比完才返回 耗时恒定

时间攻击的原理:比较 100 位的哈希,第一位就错和最后一位才错,耗时差几纳秒。 几纳秒乘几百万次,统计上就能看出来。 这条在本地根本测不出来(噪声远大于几纳秒),但写成 compare_digest 是零成本的, 没必要省这一行。 注意:这里说的「恒定时间」只对「比哈希」有意义。 你不可能让整个登录过程恒定时间——查用户那一步的耗时本来就随数据变。 所以这条属于「知道就顺手做,不知道也别慌」的级别。

五、动手 Demo:加盐哈希、四步校验、三次锁定

这一段真跑三件事: PBKDF2 与 MD5 的耗时对比、同一密码配不同盐的结果、以及七次登录尝试的完整状态变化。 重点看第三节那七行——尤其是「密码正确却失败」那一行。

import hashlib, hmac, secrets

def salt(n=16):
    return secrets.token_hex(n)

def hpass(pw, s, rounds=100000):
    return hashlib.pbkdf2_hmac('sha256', pw.encode(),
                               s.encode(), rounds).hex()

print('MD5   :', hashlib.md5(b'Hr@2026').hexdigest())
print('PBKDF2:', hpass('Hr@2026', salt())[:32])

def login(uname, pw):
    r = c.execute('SELECT id, upass, salt, role, fails, lock_until'
                  ' FROM tb_user WHERE uname = ?', (uname,)).fetchone()
    if r is None:
        return '失败:用户名或密码错'
    if r[5] and r[5] > NOW:          # 锁定检查必须在密码校验之前
        return '失败:账号已锁定至 ' + r[5]
    if hmac.compare_digest(hpass(pw, r[2]), r[1]):
        c.execute('UPDATE tb_user SET fails = 0 WHERE id = ?', (r[0],))
        return '成功:角色 = ' + r[3]
    f = r[4] + 1
    if f >= 3:
        c.execute('UPDATE tb_user SET fails = 0, lock_until = ?'
                  ' WHERE id = ?', (LOCK_TO, r[0]))
        return '失败:密码错,第 %d 次,已锁定 5 分钟' % f
    c.execute('UPDATE tb_user SET fails = ? WHERE id = ?', (f, r[0]))
    return '失败:密码错,第 %d 次' % f

实跑输出:

--------------------------------------------------------------------
登录模块:三重校验与密码存储
--------------------------------------------------------------------


  ① 登录四步
  --------------------------------------------------------------------
| 步 | 做什么 | 失败怎么办 |
|---|---|---|
| 1 | 按用户名查一行 | 统一提示「用户名或密码错」 |
| 2 | 查是否被锁定 | 提示「已锁定,稍后再试」 |
| 3 | 用存的盐重算密码比对 | 失败次数 +1 |
| 4 | 写登录日志、进主窗体 | 按角色决定菜单 |

  第 1 步失败时提示为什么要「统一」? 因为分开提示等于告诉对方「这个用户名存在」。
  这就是给撞库的人送情报。

  ② 密码不能存明文:从 MD5 到加盐慢哈希
  --------------------------------------------------------------------

  MD5       一轮:不到 0.01 ms
  SHA-256   一轮:不到 0.01 ms
  PBKDF2   10 万轮:101.74 ms

  同样的密码,同样的盐,算两次结果一样吗?
    h1 = 0c7aaff594167e9429c296c8f2f6c671
    h2 = 0c7aaff594167e9429c296c8f2f6c671
    一样 → 哈希是确定性的,所以「盐」必须每人一个
  换个盐:b67c5e88fbb5ec799c71a8e6e669da39
  MD5 的问题不是「能被反推」,是「算得太快」。一张显卡一秒能算几十亿次 MD5,八位密码几小时就撞完。
  PBKDF2 十万轮把这个数字压到「一秒几百次」,同样的字典要算几十年——慢,才是它的功能。
  盐解决的是另一件事:两个人密码一样,哈希值不能一样。
  不然攻破一张表等于攻破所有用了同一张彩虹表的系统。

  ③ 真跑一遍:四种结果
  --------------------------------------------------------------------

  admin    / admin@123  → 成功:角色 = 管理员
  admin    / wrong      → 失败:密码错,第 1 次
  admin    / wrong      → 失败:密码错,第 2 次
  admin    / wrong      → 失败:密码错,第 3 次 → 已锁定 5 分钟
  admin    / admin@123  → 失败:账号已锁定至 2026-10-31 12:05
  nobody   / x          → 失败:用户名或密码错
  hr01     / hr01@123   → 成功:角色 = 人事
  第 5 行最值得看:密码是对的,但还是失败。
  因为第 4 次失败已经把它锁了——锁定检查必须在密码校验之前。
  「锁 5 分钟」而不是「永久锁」,是因为永久锁会被人故意用来锁死别人的号。
  这是安全性和可用性的正面冲突,标准答案通常是「锁 + 通知本人」。

  ④ 比对要用「恒定时间」
  --------------------------------------------------------------------

  hmac.compare_digest(a, b)  → 按位全比完才返回
  a == b                      → 第一个不同就返回,耗时泄露位置
  时间攻击的原理:比较 100 位的哈希,第一位就错和最后一位才错,耗时差几纳秒。
  几纳秒乘几百万次就能统计出来——所以密码比对要用恒定时间函数。
  这条在本地跑不出来(噪声远大于几纳秒),但写成 compare_digest 是零成本的,没必要省这一行。

PBKDF2 单次一百毫秒,看着很慢。 但登录一次一百毫秒,人根本感觉不到—— 而攻击者要试一百万次,就得等一天多。 这就是「把成本转嫁给攻击者」的典型做法。 盐是明文存在库里的,不需要保密。 它的作用是「让两个人的哈希不一样」,不是「让别人算不出来」。 很多人把盐当成第二道密码,这是误解。 hmac.compare_digest 在 Python 里对 str 和 bytes 都支持,但两边类型要一致。 一个 str 一个 bytes 会直接抛 TypeError—— 这个坑在真实项目里很常见,因为库里的哈希有人存成 varchar 有人存成 varbinary。

30 秒实验

  1. 把 rounds 从 10 万改成 1 万,测一下耗时。再想想:降低轮数省下的那一百毫秒,值得吗?
  2. 把锁定检查挪到密码校验之后,重跑那七次尝试,看第 5 行变成什么。 这就是「顺序写反」的实际后果。
  3. 给 tb_user 加一列 last_login,登录成功时更新。 顺便想想:这个字段该不该给「人事」看? 该——但只能看自己下属的。

常见误区

  1. 「MD5 加个盐就安全了」。 单条哈希还是几小时能撞出来。 关键是慢,不是盐。
  2. 「先比密码,不对再判断锁定」。 顺序反了,锁定等于没有。 正确密码却登不进去,才是锁定生效的证据。
  3. 「密码错了要提示『密码错误』」。 这等于告诉对方「用户名是对的」。 统一提示,别送情报。
自测三问(点开对答案) 1. 为什么锁定检查必须在密码校验之前? —— **反了的话,攻击者可以一直试密码**,只是每次都被判「密码错」,**锁定永远不会触发。** 2. MD5 的问题到底是什么? —— **不是能被反推,是算得太快**——**一张显卡一秒几十亿次,八位密码几小时撞完。** **慢哈希(PBKDF2)才是解法。** 3. 盐要不要保密? —— **不要。** **盐是明文存库的**,**它的作用是让相同密码的哈希不同**,**不是加第二道密码。**

下一篇 19.6,进主窗体: 菜单不是画出来的,是按角色算出来的——顺便看一个真实的坑:主窗体关了,进程还在。

相关笔记