登录三步:非空校验、参数化查人、验密。第一步最容易被跳过,也最致命。 实测四种输入:空 / 不存在 / 密码错 / 正确——分别给出四种回答。 登录成功返回的不只是「成功」,还有「角色」——它决定了后面能看什么。
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 秒实验
- 把
(u_,)改成(u_),跑一次。会报参数错误——这就是「一个元素的元组」那个坑。 - 把
login()改成「无论哪种失败都返回同一句话」,跑一遍。 四行输出变成三行一样、一行成功——这就是真实系统对用户的回答。 - 想一想:如果登录成功之后,用户在系统里改了自己的密码,会话要怎么办? 最稳的做法是「让所有会话失效、重新登录」——因为改密码的常见场景就是「怀疑密码泄露了」。
常见误区
- 「登录就是查一下有没有这条记录」。 还要验密、还要带回角色、还要记日志、还要限次数。 少任何一条,都不是「能上线」的登录。
- 「把密码哈希也放进会话,省得再查」。 省一次查询,赔一次泄露。 会话里只存「id、角色、姓名」。
- 「报错越详细越好,方便用户」。 对用户详细=给攻击者情报。 对用户统一说「账号或密码错误」,对日志分开记。
自测三问(点开对答案)
1. 登录的三步是什么?为什么第一步不能省? —— **查空、参数化查人、验密。** 第一步省了可能出现「空账号命中第一行」—— **等于「什么都不填」也能登进去。** 2. 为什么登录成功要返回角色? —— **因为登录模块的职责是「判断能进之后他是谁、能看什么」。** **只返回 `true` 的话,后面每个模块都得自己再查一次角色。** 3. 登录失败时,对用户和对日志的回答为什么不一样? —— **对用户要统一说「账号或密码错误」**(否则等于送了账号探测器); **对日志要分开记**(排查时才知道是打错了还是在被猜)。下一篇 18.6 讲主窗体:菜单树怎么按角色过滤、为什么「菜单过滤」不等于「权限控制」, 以及主窗体上那几个看着土但很有用的区域。
- ☐ (本篇完,下一篇 18.6 主窗体模块设计)