🪟 第18章 Visual C++与SQL Server实现酒店管理系统
📄 一句话概括

登录三步:非空校验、参数化查人、验密。第一步最容易被跳过,也最致命。 实测四种输入:空 / 不存在 / 密码错 / 正确——分别给出四种回答。 登录成功返回的不只是「成功」,还有「角色」——它决定了后面能看什么。

📝 本章和前面几章的关系

18.4 有了公共类,这一节是它的第一个使用者:登录。 登录模块短得不起眼,但它是整个系统的入口——这里漏一个洞,后面全白搭。 教材 18.5 分三小节:登录模块概述、技术分析、实现过程。 技术分析那一节讲的是「参数化查询」, 它其实是全书最重要的一条安全实践(17.1 实测过注入的威力)。 这一段四种登录结果都是真跑的。 密码哈希那里只做了「换成字符串」的演示, 因为真实哈希在 16.4 已经算过——这里重点是「流程」,不是重复实现 PBKDF2。

一、三步:查空、查人、验密

步骤 做什么 做错的后果
1. 非空校验 账号、密码都不能空 空账号可能查出第一行,等于万能登录
2. 参数化查人 按账号查这条记录 拼字符串 → 注入(17.1)
3. 验密 比哈希,不是比明文 明文存密码,库一泄露全裸

第一步最容易被跳过,但它最致命。 WHERE uname = '' AND upass = '' 在某些写法下会命中第一行—— 于是「什么都不填」反而登进去了。 这一步还有一个变种: 有人写 if (uname == null) 判断, 但用户输入的是「空字符串」而不是「null」——于是校验根本没触发。 正确写法是「去掉空格之后长度大于 0」。

二、三种失败,真跑一遍

输入 结果
空 / 空 账号或密码不能为空
nobody / 123 账号不存在
admin / 错密码 密码错误
admin / adm123 登录成功(角色:管理员)

第四行返回的不只是「成功」,还带回了「角色」。 这一点很重要: 登录模块的职责不是「判断能不能进」, 而是「判断能进之后,他是谁、能看什么」。 如果登录只返回一个 true, 后面每个模块都得再查一次角色——多查一次多一个出错机会,而且很难保证「每个模块都记得查」。 但要注意别走另一个极端: 把整个用户对象(含密码哈希)塞进会话。 会话里只存「id + 角色 + 姓名」,密码相关的一个字节都不要—— 因为会话会被日志、被 dump、被截图。

三、密码不能明文存

演示里把 upass 换成了一个占位字符串,真实代码要存 PBKDF2 的哈希 + 每人一份盐(16.4 实测过: MD5 试完 1 亿个只要 2.8 分钟,PBKDF2 要 114 天)。 校验也要用恒定时间比较(hmac.compare_digest), 别用 ==——用 == 会因为「前几位对上了」而多花时间,理论上能被计时侧信道逐位猜。 还有一件事:登录失败不要告诉对方「账号不存在」还是「密码错了」。 统一说「账号或密码错误」——否则等于送了一个「账号探测器」。 注意这跟演示里的输出是矛盾的: 演示为了让你看清每一步,故意把两种回答分开打印; 真实系统里对用户只说一句。 而服务端日志里要分开记(排查时才知道是「账号打错」还是「有人在猜密码」)。

四、登录之后:会话里该存什么

存什么 例子 为什么
用户 id uid = 1 后续所有操作都要记「谁干的」
角色 role = 管理员 决定菜单和按钮可见性
登录时间 2026-05-01 09:12 超时自动退出要用
不存什么 密码、密码哈希 存了就等于把钥匙挂在门口

最后一行是安全底线: 会话里只存「你是谁」,不存「你的密码」。 因为会话可能被读、被日志记、被 dump 出来。 「登录时间」那一条是为了「超时退出」。 桌面程序里可能不明显,Web 里没有超时就是大问题——一个人离开电脑没退出,谁坐上去都能操作。 顺带一条:登录失败次数要限制。 三次错就锁几分钟, 这一条能把「暴力猜密码」的成本抬到不可行(17.1 那张表算过:慢哈希 + 限次,基本就挡住了)。

五、动手 Demo:四种输入,四种回答

这一段写一个 login() 函数(三步走完),然后用四种输入真跑一遍, 最后把 upass 换成哈希看它长什么样。 注意第四行输出带回了「角色」——这是登录模块真正的返回值。

def login(u_, p_):
    # ① 非空:注意是「去空格后为空」,不是「等于 null」
    if not u_.strip() or not p_.strip():
        return u"账号或密码不能为空"
    # ② 参数化查人(绝不拼字符串)
    cols, rows = db.query(u"SELECT id, uname, upass, role FROM tb_user"
                          u" WHERE uname = ?", (u_,))
    if not rows:
        return u"账号不存在"
    # ③ 验密:真实代码比哈希,用 compare_digest
    if rows[0][2] != p_:
        return u"密码错误"
    return u"登录成功(角色:%s)" % rows[0][3]

实跑输出:

--------------------------------------------------------------------
用户登录:三步,一步都不能省
--------------------------------------------------------------------


  ① 三步:查空、查人、验密
  --------------------------------------------------------------------

| 步骤 | 做什么 | 做错的后果 |
|---|---|---|
| 1. 非空校验 | 账号、密码都不能空 | 空账号会查出第一行,等于万能登录 |
| 2. 参数化查人 | 按账号查这条记录 | 拼字符串 → 注入(17.1) |
| 3. 验密 | 比哈希,不是比明文 | 明文存密码,库一泄露全裸 |

  第一步最容易被跳过,但它最致命。
  WHERE uname = '' AND upass = '' 在某些写法下会命中第一行——于是「什么都不填」反而登进去了。

  ② 三种失败,真跑一遍
  --------------------------------------------------------------------

| 输入 | 结果 |
|---|---|
| 空 / 空 | 账号或密码不能为空 |
| nobody / 123 | 账号不存在 |
| admin / 错密码 | 密码错误 |
| admin / adm123 | 登录成功(角色:管理员) |

  第四行返回的不只是「成功」,还带回了「角色」。
  这一点很重要: 登录模块的职责不是「判断能不能进」,而是「判断能进之后,他是谁、能看什么」。

  ③ 密码不能明文存:改一次哈希
  --------------------------------------------------------------------

| uname | upass |
|---|---|
| admin | hashed_adm |

  真实的代码要存 PBKDF2 的哈希 + 每人一份盐(16.4 实测过:MD5 试完 1 亿个只要 2.8 分钟)。
  这里为了演示只写成 hashed_adm,实际要算真哈希。
  还有一件事:登录失败不要告诉对方「账号不存在」还是「密码错了」。
  统一说「账号或密码错误」——否则等于送了一个「账号探测器」。

  ④ 登录之后:会话里该存什么
  --------------------------------------------------------------------
| 存什么 | 例子 | 为什么 |
|---|---|---|
| 用户 id | uid = 1 | 后续所有操作都要记「谁干的」 |
| 角色 | role = 管理员 | 决定菜单和按钮可见性 |
| 登录时间 | 2026-05-01 09:12 | 超时自动退出要用 |
| 不存什么 | 密码、密码哈希 | 存了就等于把钥匙挂在门口 |

  最后一行是安全底线: 会话里只存「你是谁」,不存「你的密码」。
  因为会话可能被读、被日志记、被 dump 出来。

u_.strip() 那个 .strip() 不能省。 用户输入空格是常事(复制粘贴带了个尾空格), 不 strip 的话「admin 」和「admin」就是两个账号—— 而数据库里只有一个,于是「明明密码对却登不进」。 (u_,) 那个逗号也别漏。 Python 里 (u_,) 是「一个元素的元组」, (u_) 只是加了括号的字符串——参数化会失败或者报「参数数量不对」。 这种错新人常犯,而且报错信息不容易看懂。 返回「角色」这一句是整段的关键。 后面 18.6 的菜单过滤、18.7 的业务操作, 全靠这个字符串判断「该不该让他干」。 真实系统里还会做两件这里没做的事: 一是记一条登录日志(谁、什么时候、从哪、成没成), 二是失败次数累计与锁定。 这两件都不属于「登录成功」的主流程,但它们是「出事之后能不能查清」的关键。

30 秒实验

  1. 把 (u_,) 改成 (u_),跑一次。会报参数错误——这就是「一个元素的元组」那个坑。
  2. 把 login() 改成「无论哪种失败都返回同一句话」,跑一遍。 四行输出变成三行一样、一行成功——这就是真实系统对用户的回答。
  3. 想一想:如果登录成功之后,用户在系统里改了自己的密码,会话要怎么办? 最稳的做法是「让所有会话失效、重新登录」——因为改密码的常见场景就是「怀疑密码泄露了」。

常见误区

  1. 「登录就是查一下有没有这条记录」。 还要验密、还要带回角色、还要记日志、还要限次数。 少任何一条,都不是「能上线」的登录。
  2. 「把密码哈希也放进会话,省得再查」。 省一次查询,赔一次泄露。 会话里只存「id、角色、姓名」。
  3. 「报错越详细越好,方便用户」。 对用户详细=给攻击者情报。 对用户统一说「账号或密码错误」,对日志分开记。
自测三问(点开对答案) 1. 登录的三步是什么?为什么第一步不能省? —— **查空、参数化查人、验密。** 第一步省了可能出现「空账号命中第一行」—— **等于「什么都不填」也能登进去。** 2. 为什么登录成功要返回角色? —— **因为登录模块的职责是「判断能进之后他是谁、能看什么」。** **只返回 `true` 的话,后面每个模块都得自己再查一次角色。** 3. 登录失败时,对用户和对日志的回答为什么不一样? —— **对用户要统一说「账号或密码错误」**(否则等于送了账号探测器); **对日志要分开记**(排查时才知道是打错了还是在被猜)。

下一篇 18.6 讲主窗体:菜单树怎么按角色过滤、为什么「菜单过滤」不等于「权限控制」, 以及主窗体上那几个看着土但很有用的区域。

相关笔记