🛢️ 第23章 Python+MySQL实现在线学习笔记
📄 一句话概括

注册的「查重」代码要写(为了友好提示),但兜底必须靠唯一约束——查和插之间有窗口。 密码哈希的「慢」是特性不是缺陷:算得越快,攻击者能猜的越多。 退出登录的本质是「让那个串失效」,不是「告诉浏览器忘掉 cookie」。

📝 本章和前面几章的关系

教材 23.6 讲用户模块:注册、登录、退出、权限管理。 四件事里前三件是常规操作,第四件才是决定系统安不安全的地方。 这一篇的重点有三个:注册时的「查与插之间有个窗口」、密码为什么要用慢哈希、 以及权限检查为什么要做成装饰器。 三件事的共同点是:写错了都不报错。 演示里把「两个请求同时查到用户名可用」这个窗口显式摆了出来, 然后用唯一约束把第二个插入挡掉。另外量了 md5 和 pbkdf2 差多少倍。

一、注册:查重和兜底是两件事

几乎所有教程都写「先查用户名是否存在,不存在就插入」。这个写法在单用户下没问题。 但「查」和「插」是两个动作,中间有窗口:

时刻 请求 A 请求 B
t1 查 newbie → 不存在 —
t2 — 查 newbie → 不存在
t3 插入成功 —
t4 — 插入……

第四步会发生什么,完全取决于 uname 上有没有唯一约束。 有,B 的插入被拒绝;没有,库里出现两个 newbie,而「用户名登录」从此不确定。 所以正确做法是「两层都要」:代码里查一次(为了给「用户名已被占用」这种友好提示), 数据库上加唯一约束(为了兜底)。 只做前者有并发漏洞,只做后者提示不友好——但两者的重要性不对等:前者是体验,后者是正确性。

二、密码:为什么必须慢

哈希函数的设计目标是「快」——比如文件校验,越快越好。密码哈希正好相反。

场景 希望快还是慢 为什么
文件校验 越快越好 算一次就扔
密码存储 越慢越好 要拖住暴力破解

这张表只写了一行,因为密码哈希的情况是反的:攻击者手里有一堆哈希值,他要做的是「猜一个明文、 算一次、比一次」。 算得越快,单位时间能猜的越多。所以「慢」在这里是防护手段。

算法 一次耗时 结论
md5 微秒级 太快,不适合存密码
pbkdf2 十万次 百毫秒级 适合

演示里这两个数差了两三个数量级——同一个密码,md5 算是 0.038 毫秒,pbkdf2 十万次要 99 毫秒。 这个差别在正常登录时完全感觉不到(一百毫秒而已),但在暴力破解时是致命的: 攻击者每多猜一个密码,都要付同样的代价。 盐的作用是另一个维度:它让「同一个密码」在不同用户那里算出不同的值。 没有盐的话,两个用户都用了 123456,哈希值一样——攻击者破一个等于破两个, 而且彩虹表可以直接查。

三、登录与退出:串的生成与销毁

登录成功时做两件事:生成一个随机串,把它和用户 id 的对应关系存进服务端。 之后每次请求带着这个串来,服务端查一下就知道是谁。

动作 做什么 做错的后果
登录 生成串、存映射、设 cookie 不换串 → 会话固定
访问 用串查映射 串放内存 → 重启就掉线
退出 删掉映射 只删 cookie → 串仍然有效

第三行是最容易被误解的:退出登录不是「告诉浏览器忘掉」,是「让服务端那条记录失效」。 因为 cookie 在客户端,用户完全可以不删——只有服务端删了,那个串才真的没用。 第二行值得顺带一提:会话存哪,决定了「重启会不会掉线」。 存进程内存里,重启就全掉;存数据库或 Redis,重启不影响。 演示里用的是数据库表,所以重启不掉线——代价是每次请求多一次查询。

四、权限:做成结构,不做成习惯

「这个页面要登录才能看」这件事,最直接的写法是每个函数开头加一句判断。 问题是:漏写不报错。

做法 漏写的后果 能不能查出来
每个函数手写 if 那一页变成公开的 只能逐页看
装饰器 / 中间件 不可能漏 不存在这个问题

第二行的价值在于「把必须做的事变成默认行为」。 装饰器不是「检查得更严」,是「只写一次、不用重复」——所以也就不存在「忘了写」。 这一点和前面几条是同一个思路: 按人隔离收进操作类、软删除条件收进视图、登录检查收进装饰器。 三件事的手法一样——不是「提醒自己记得」,是「让忘了这件事变得不可能」。 这也是这一章反复出现的那句话的另一面:安全的反面不是「出错」,是「静默地少做了一步」。 而对付「静默少做一步」的唯一办法,就是让它变成结构的一部分。

五、动手 Demo:注册、登录、退出:三道门和一次并发

这一段先走注册的两种写法,然后把「两个请求同时查到可用」这个窗口摆出来,看唯一约束怎么兜底; 再量 md5 和 pbkdf2 的耗时差;最后做一次登录、一次退出,以及一个装饰器式的权限检查。

import hashlib, sqlite3, uuid

c = sqlite3.connect(u"user.db", isolation_level=None)
c.executescript(u"""
CREATE TABLE users (id INTEGER PRIMARY KEY, uname TEXT NOT NULL UNIQUE,
  upass TEXT, salt TEXT);
CREATE TABLE notes (id INTEGER PRIMARY KEY, user_id INTEGER, title TEXT);
CREATE TABLE tb_sess (sid TEXT PRIMARY KEY, uid INTEGER);
""")

def register(uname, raw):
    salt = u"salt_" + uname
    h = hashlib.pbkdf2_hmac(u"sha256", raw.encode(), salt.encode(), 100000).hex()
    try:
        c.execute(u"INSERT INTO users (uname, upass, salt) VALUES (?,?,?)",
                  (uname, h, salt))
        return u"注册成功"
    except sqlite3.IntegrityError:
        return u"用户名已存在(唯一约束拦住)"

print(register(u"newbie", u"123456"))
print(register(u"newbie", u"654321"))

# 两个请求同时查到「可以用」
a = c.execute(u"SELECT COUNT(*) FROM users WHERE uname=?", (u"racer",)).fetchone()[0]
b = c.execute(u"SELECT COUNT(*) FROM users WHERE uname=?", (u"racer",)).fetchone()[0]
print(u"两个请求都查 -> %d 和 %d" % (a, b))
print(u"第一个插 -> %s" % register(u"racer", u"111111"))
print(u"第二个插 -> %s" % register(u"racer", u"222222"))

# 慢哈希 vs 快哈希
import time
t = time.perf_counter()
hashlib.md5(b"123456salt").hexdigest()
t_md5 = time.perf_counter() - t
t = time.perf_counter()
hashlib.pbkdf2_hmac(u"sha256", b"123456", b"salt", 100000)
t_pbk = time.perf_counter() - t
print(u"md5 %.3f ms / pbkdf2 %.1f ms" % (t_md5 * 1000, t_pbk * 1000))

# 登录、退出
sid = uuid.uuid4().hex
c.execute(u"INSERT INTO tb_sess VALUES (?,?)", (sid, 1))
print(u"登录后带着串来 -> uid=%s" % c.execute(
      u"SELECT uid FROM tb_sess WHERE sid=?", (sid,)).fetchone()[0])
c.execute(u"DELETE FROM tb_sess WHERE sid=?", (sid,))
print(u"退出后剩 %d 条" % c.execute(
      u"SELECT COUNT(*) FROM tb_sess WHERE sid=?", (sid,)).fetchone()[0])
c.close()

实跑输出:

--------------------------------------------------------------------
注册、登录、退出:三道门和一次并发
--------------------------------------------------------------------

**教材 23.6 是用户模块:注册、登录、退出、权限管理。**
**四件事里前三件是常规操作,第四件(权限)才是决定系统安不安全的地方。**

  ① 注册:唯一约束 vs 先查再插
  --------------------------------------------------------------------
第一次            注册成功
先查再插           用户名已存在(查询发现)
**两种写法在单用户场景下结果一样,差别在并发。**
**「先查再插」的「查」和「插」之间有一个时间窗口,两个请求可以同时查到「不存在」。**
**下面把这个窗口显式地摆出来:**
两个请求都查         都得到「0 条,可以注册」
第一个插           注册成功
第二个插           用户名已存在(唯一约束拦住)
**最后一行是唯一约束起的作用——它在「两个人同时查完」之后,把第二个插入挡了下来。**
**如果没有这个约束,数据库里会出现两个 `racer`,而「用用户名登录」这个功能从此不确定。**
**所以「查重」这件事,代码要查(为了给友好提示),但也必须靠唯一约束兜底。**

  ② 密码:慢哈希,慢是特性
  --------------------------------------------------------------------
算法              一次耗时  适合存密码吗
----------------------------------------
`md5`             0.038 ms  不适合,太快
`pbkdf2` 10 万次  99.4 ms   适合
**「快」对哈希函数是优点,对密码哈希是缺点。**
**因为攻击者拿到的是一堆哈希值,他要做的是「猜一个、算一次、比一次」。**
**算得越快,能猜的越多。**
**所以密码哈希的设计目标正好相反:让它慢到「正常登录感觉不到」、「暴力破解慢到不划算」。**
**10 万次迭代正常登录只要几十毫秒,**
**而攻击者要多猜一个密码就多付同样的时间——这就是「慢」的价值。**
**盐的作用是另一个维度:让「同一个密码」在不同用户那里算出不同的值,彩虹表就失效了。**

  ③ 登录与退出:一个串的生成和销毁
  --------------------------------------------------------------------
登录成功           发 sid=f03caf0b…,库里记 uid=11
带着 sid 来       认出 uid=11
退出登录           删掉这一行,0 条剩
**退出登录的本质是「让那个串失效」,不是「告诉浏览器忘掉 cookie」。**
**因为 cookie 是客户端的,用户完全可以不删——只有服务端删了记录,那个串才真的没用。**
**这一点和第 100 段那个会话固定是同一个道理:可信的东西必须在服务端。**

  ④ 权限:一个装饰器管住所有页面
  --------------------------------------------------------------------
已登录            50 篇
未登录            跳转到登录页
**装饰器(或 Flask 的 `before_request`)的作用不是「检查得更严」,是「只写一次」。**
**如果每个函数开头都手写一遍 `if not session: redirect`,那只要有一个人漏写,**
**那一页就是公开的。**
**而漏写不会报错,页面照常显示——只是不需要登录也能看。**
**所以这类「必须做、但不做也不会报错」的检查,正确的做法是让它变成【结构】的一部分:装饰器、**
**中间件、基类。而不是靠每个人记得写。**

第三段那五行是这一段的核心:两个请求先后查到「0 条,可以用」,然后第一个插入成功、 第二个被唯一约束拦住。 如果没有唯一约束,这里就会出现两条 racer——而「用用户名登录」从此不知道该取哪一条。 第四段两个数差两三个数量级。 0.038 毫秒对 99 毫秒,正常登录感觉不到后者,但暴力破解时这个差距就是「一小时」和「一年」。 所以看到「这个哈希算法很快」这句话,要分清是夸还是骂。 第五段两行说明退出的本质:删掉服务端那一条,串就失效了。 这里没有动 cookie,因为「客户端记不记得」不影响安全性——只要服务端不认它。

30 秒实验

  1. 把 uname 的唯一约束去掉,重跑第三段,看会不会出现两条 racer。
  2. 把 pbkdf2 的迭代次数从 10 万改成 1 万,看耗时降到多少、还够不够慢。
  3. 把会话从表换成内存里的字典,重启进程,看登录状态还在不在。

常见误区

  1. 只写「先查再插」不加唯一约束。两个请求同时查到「不存在」就会都插进去。 查是为了提示,约束才是兜底。
  2. 用 md5 或 sha1 存密码。它们太快,等于帮攻击者省时间。 要用 pbkdf2、bcrypt、argon2 这类慢哈希,并且加盐。
  3. 退出登录只删 cookie。cookie 在客户端,用户不删也没人管。 只有服务端删掉那条记录,串才真的失效。
自测三问(点开对答案) 1. 「先查再插」的漏洞在哪? —— 在「查」和「插」之间有一个时间窗口,两个请求可以同时查到「不存在」。 唯一约束才能保证只有一个插入成功。 2. 为什么密码哈希要故意慢? —— 攻击者是「猜一个、算一次、比一次」,算得越快能猜的越多。 慢到正常登录感觉不到、暴力破解不划算,就是它的设计目标。 3. 权限检查为什么做成装饰器? —— 因为它「漏写不报错」。手写 `if` 每页一次,漏一页那页就是公开的。 装饰器让它变成默认行为,「忘了写」这件事就不存在了。

用户这块收尾,下一篇是笔记模块:列表、添加、编辑、删除,四个动作每一个都要带归属条件。

相关笔记