📘 第八章 确认访问用户身份的认证
📄 一句话概括

表单认证没有任何标准:字段叫什么、成功后发什么、会话 ID 怎么生成,全是实现者自己定。 但有几条实践已经被固定下来——POST、HTTPS、HttpOnly、登录后换 ID。

📝 本节和前面几节的关系

前三节都是「有规范可依」的方案。 这一节开始是「没有规范、全靠约定」的方案,而它恰恰是今天用得最多的。

一、没有标准,所以各家都不一样

HTTP 认证有 BASIC、DIGEST 两套标准,字段名、挑战格式、失败回应都写死了。 表单认证什么都没有——规范里根本没这个东西。 于是每个网站自己决定:账号密码放正文还是首部?字段叫 user 还是 username? 成功后发 cookie 还是 JWT?会话 ID 多长?过期多久? Demo 里只用了最朴素的一种:POST 表单编码的正文,成功后 302 跳到 /me, 同时 Set-Cookie: SESSIONID=...。请求 134 字节,正文 24 字节。

二、会话 ID 的强度

整个方案的安全重心就落在那串 ID 上。 Demo 里用 secrets.token_hex(16) 生成,32 个十六进制字符 = 128 位,取值空间 2^128 ≈ 3.4e38。 这个量级意味着猜是猜不到的——即使每秒试十亿个,也要跑上万亿年。 所以攻击者不会去猜 ID,他们会去偷:XSS 读 cookie、中间人抓明文、日志泄露、Referer 带出去。 反过来说,凡是自己拼 ID 的实现都要警惕。用时间戳、用自增数、用 md5(用户名)——这些都能被推出来。 Demo 里随便发一个全 a 的 ID,服务端直接 302 回登录页,说明它压根不在会话表里。

三、已经被固定下来的几条

一、用 POST。GET 会把账号密码写进 URL,进而进浏览器历史、进服务器访问日志、进 Referer。 这一条几乎没有例外。 二、必须走 HTTPS。表单认证的密码是明文正文,没有 TLS 就是公开的。 三、cookie 带 HttpOnly。脚本读不到,XSS 就没法直接偷 ID。 再加 Secure 限定只在 HTTPS 上发,加 SameSite 防跨站带上。 四、登录成功后换新 ID。这一条是 8.5.2 的主题——不换就是会话固定漏洞。

四、动手 Demo:提交表单,换一个 128 位的会话 ID

一个极简的表单登录:POST 提交账号密码,成功后回 302 并下发会话 ID; 然后量这串 ID 的位数和取值空间,再用一个瞎猜的 ID 去访问受保护页面。

if first.startswith(u'POST'):
    form = dict(kv.split(u'=', 1) for kv in body.split(u'&'))
    if USERS.get(form[u'user']) == form[u'pass']:
        sid = secrets.token_hex(16)         # 128 位随机
        return 302 + [(u'Set-Cookie',
                        u'SESSIONID=%s; Path=/; HttpOnly' % sid)]
    return 200 + u'密码不对'

实跑输出:

--------------------------------------------------------------------
表单认证:标准没规定,各写各的
--------------------------------------------------------------------
  表单认证没有标准。真正决定它长什么样的,是这几处自选动作:
    账号密码放在正文里还是首部里?—— 多数放正文(POST + 表单编码)
    认证成功后发什么?—— 多数发一个会话 ID 放进 cookie
    会话 ID 怎么生成、多长、存哪儿?—— 各家自己定

  第一步,提交表单(请求 134 字节,正文 24 字节):
    正文:user=bozai&pass=pa55word
    HTTP/1.1 302 Found
    Location: /me
    Set-Cookie: SESSIONID=506019638f8cf3bac74fb03dc7e2be50; Path=/; HttpOnly

  会话 ID 这串东西的强度:
    长度            32 个十六进制字符 = 128 位
    取值空间        2^128 ≈ 3.4e+38
    生成方式        secrets.token_hex(16),密码学安全随机
    放进 cookie 时带了 HttpOnly,脚本读不到

  随便猜一个 ID 去访问 /me:
    HTTP/1.1 302 Found
  带上真的那个:
    HTTP/1.1 200 OK,正文 36 字节

  这段流程里没有一行是规范规定的。「表单字段叫什么」「会话 ID 多长」
  「放 cookie 还是放 URL」,全是实现者自己定的——这也是为什么每个网站的登录接口长得都不一样。
  但有几条已经被实践固定下来了:POST、HTTPS、HttpOnly、登录成功后换一个新的会话 ID。

提交表单:请求 134 字节、正文 24 字节, 回 302 带 Location: /me 和 Set-Cookie: SESSIONID=...。 会话 ID 32 个十六进制字符 = 128 位,取值空间 2^128 ≈ 3.4e38。带上 HttpOnly,脚本读不到。 随便猜一个全 a 的 ID:回 302 跳回登录页——它不在会话表里。 带上真的那个:回 200,正文 36 字节。 整个流程没有一行是规范规定的。这就是表单认证的现状:约定俗成,各自实现。

30 秒实验

  1. 把 token_hex(16) 改成 str(int(time.time())),想想要多久能被猜中。
  2. 去掉 cookie 里的 HttpOnly,想一下 XSS 能做什么。
  3. 想一想:为什么登录表单必须用 POST,而不能用 GET?

常见误区

  1. 「表单认证有规范」——没有,全是各家约定。
  2. 「会话 ID 越长越好」——128 位已经够,关键是随机性和保管方式。
  3. 「登录用 GET 也没关系」——会进 URL、历史、日志、Referer。
自测三问(点开对答案) 1. 表单认证的会话 ID 一般多长才够? —— 128 位随机(32 个十六进制字符)就足够,猜不到。 2. 为什么 cookie 要加 HttpOnly? —— 让脚本读不到会话 ID,XSS 就没法直接偷。 3. 登录表单为什么不能用 GET? —— 账号密码会进 URL、浏览器历史、访问日志和 Referer。

会话 ID 发下去了,接下来是怎么管。管不好就有会话固定攻击。

相关笔记