🐍 第12章 留言板网站应用程序
📄 一句话概括

HTTP 是无状态的:每个请求都是独立的,服务器不记得上一个请求是谁发的。 不记事,就没法做登录、购物车、浏览记录。 Cookie 存在浏览器那边,Session 存在服务器那边。 两个经常一起用——Cookie 里只放一个编号,真正的数据在服务器上。 Flask 的 session 默认是「签名后放在 Cookie 里」,是签名不是加密:内容明文可解, 只是改不了。

📝 本章和前面几章的关系

上一篇的留言板谁都能留言,因为它不知道「你是谁」。这一篇补上这一层。 教材第 12 章最后三节是【认识 Cookie 和 Session】【管理员登录】和【再谈 Cookie 与 Session】。 演示里的 Cookie 交互是真的:Set-Cookie 头、签名 Cookie、改一个字符就失效,全部实测。 末节那条「密码不能存明文」是教材没展开但必须知道的。这条不知道,做出来的登录等于没有。

一、HTTP 本身不记事

演示第一段连发两次请求,两次带的 Cookie 都是 0 个。 HTTP 是无状态的:每个请求都是独立的,服务器不记得上一个请求是谁发的。 它甚至不知道这两个请求来自同一个人。 这不是缺陷,是设计。正因为无状态,一台服务器才能同时服务几百万人——它不用为每个人记一份状态。

但不记事就没法做登录、购物车、浏览记录。 补这个缺口有两招:Cookie 存在浏览器那边,Session 存在服务器那边。 实际系统里两个一起用:Cookie 里只放一个编号(或一段签名数据),真正的用户信息在服务器上。

二、Cookie 是服务器让浏览器存的

服务器在响应头里写 Set-Cookie: fav=螺栓; Max-Age=3600; Path=/,浏览器就把它存下来。 之后浏览器每次请求同一个站点,都会自动带上这个 Cookie。注意是「自动」——用户不用做任何事。 演示第二段:带着 Cookie 请求 /show 拿到「螺栓」,不带就是空的。两个结果对得上。

Max-Age=3600 是有效期(秒)。不写的话是「会话 Cookie」,关掉浏览器就没了。 Path=/ 表示这个站的所有路径都带上它。 Cookie 在客户手里,用户能看、能改、能删。 打开浏览器的开发者工具就能改。 所以规矩很硬: 任何「不能让人改」的东西都不能放在 Cookie 里存明文。 比如「我是管理员」这个标记, 绝不能写成 is_admin=true 直接放 Cookie。

三、Session:数据在服务器,Cookie 里只有钥匙

演示第三段:未登录时 /whoami 说「未登录」,/login?u=小王 之后再看就是「小王」, /logout 之后又回到「未登录」。 这时浏览器里存的 session Cookie 是 eyJ1c2VyIjoiXHU1YzBmXHU3MzhiIn0.aueLlw.pNImMrH…。 那串东西分三段,用点隔开。 第一段 base64 解出来是 {"user": "\u5c0f\u738b"}——就是「小王」两个字的 Unicode 转义。

所以 Flask 的 session 默认是「签名后放在 Cookie 里」,内容其实是明文可解的。 第二段那个签名保证内容没被改过。 注意是「签名」不是「加密」。 签名只防篡改,不防偷看。 所以 session 里别放密码、身份证号这类东西。 要真加密,得自己加密再放进去,或者换成服务端存储的 session(Flask-Session 之类)。 这两种本书不讲,知道有这个区别就行。

四、改 Cookie 试试

演示第四段把那个 Cookie 改了一个字符再发过去,/whoami 立刻变成「未登录」。 签名对不上,Flask 直接当成没有 session,把你当未登录。 这就是「Cookie 不可信、但可以用」的实现方式:内容不可信,但篡改能被发现。

为什么改一个字符就失效?因为签名是把内容和密钥一起算出来的哈希。内容变了哈希就变,对不上就拒收。 那个密钥就是 app.secret_key。 它必须是一个别人猜不到的长随机串,而且绝不能写进代码仓库。 演示里写的是 'demo-only', 只因为它只活几秒钟。 真项目里 secret_key 从环境变量读。 写死在代码里、又提交到 Git 上——任何人拿到它就能伪造任意用户的 session,登录形同虚设。

五、登录这件事的完整形状

步骤 做什么 容易错在哪
1 提交账号密码 POST 到 /login 用 GET,密码进了浏览器历史和日志
2 服务端核对 查库、比哈希 存明文密码;比字符串用 ==
3 建立会话 session['uid'] = 用户id 把整个用户对象塞进 session
4 后续请求 读 session 判断身份 只在前端判断有没有登录
5 退出 清掉 session 只删了 Cookie,服务端还认

第一条:登录必须用 POST。 用 GET 的话密码会出现在网址上,进浏览器历史、进服务器访问日志、 进公司代理的日志。 第三条:session 里只放用户 id 这种最小信息,每次要用再查库。 放整个对象进去,用户改个名字你这里还是旧的。 第四条最要命:前端隐藏按钮不叫权限控制。 别人可以直接发请求,服务端每一处都得自己判一遍。

第五条:退出要清掉服务端的 session,不是只删浏览器那个 Cookie。 只删 Cookie 的话,那把「钥匙」还能用——被人捡到照样能进。 这五步写成代码不长,但每一步都有对应的攻击方式。 做登录最该想的不是「怎么让它work」,是「怎么让它不被绕过」。

六、密码不能存明文

这一条教材没展开,但它是整篇最重要的。 密码不能存明文,要存哈希。 而且不能用普通的 md5、sha1。这两个早就不够了——网上有现成的彩虹表,常见密码的哈希值直接查得到。 要用专门给密码设计的算法:bcrypt、argon2、scrypt。 它们的共同特点是「故意算得慢」,慢到暴力破解不划算。

还有一个必须做的:加盐。 每个用户的密码配一个随机串一起哈希,这样两个人用同样的密码, 存下来的哈希也不一样,彩虹表就失效了。 验证的时候不能比哈希值相等,要用库提供的 checkpw 这类函数——它内部用的是「恒定时间比较」, 防止别人通过响应时间猜出前几位。 记住三句话:存哈希不存明文、用慢算法不用 md5、每人一个盐。 这三条守住,密码这一块就算及格了。

七、动手 Demo:Cookie、Session 和登录

先验证 HTTP 不记事:连发两次请求,两次都没带 Cookie。 然后手动设一个 Cookie,看它怎么被带回来。 再跑一遍登录、退出,把签名 Cookie 打印出来看,最后改一个字符试试。

import logging, threading
from flask import Flask, make_response, redirect, request, session, url_for
from werkzeug.serving import make_server
import requests

logging.getLogger('werkzeug').setLevel(logging.ERROR)
app = Flask(__name__)
app.secret_key = 'demo-only-not-a-real-key'

@app.route('/')
def home():
    return 'ok'

@app.route('/set')
def set_cookie():
    r = make_response('已设置')
    r.set_cookie('fav', '螺栓', max_age=3600)
    return r

@app.route('/show')
def show():
    return '你的 fav 是 %s' % request.cookies.get('fav', '(空)')

@app.route('/login')
def login():
    session['user'] = request.args.get('u', '')
    return '已登录:%s' % session['user']

@app.route('/whoami')
def whoami():
    return '当前用户:%s' % (session.get('user') or '未登录')

@app.route('/logout')
def logout():
    session.clear()
    return '已退出'

srv = make_server('127.0.0.1', 0, app)
B = 'http://127.0.0.1:%d' % srv.server_port
threading.Thread(target=srv.serve_forever, daemon=True).start()

print('① HTTP 本身不记事')
for i in (1, 2):
    r = requests.get(B + '/', timeout=5)
    print('    第%d次请求带了几个 Cookie:%d' % (i, len(r.request.headers.get('Cookie', '').split(';')) if r.request.headers.get('Cookie') else 0))
print('  HTTP 是无状态的:每个请求都是独立的,服务器不记得上一个请求是谁发的。')

print('② Cookie 是服务器让浏览器存的')
s = requests.Session()
r = s.get(B + '/set', timeout=5)
print('  GET /set  → 服务器回的响应头里有:')
for k, v in r.headers.items():
    if k.lower() == 'set-cookie':
        print('    %s: %s' % (k, v))
print('    带着这个 Cookie 再请求 /show → %s' % s.get(B + '/show', timeout=5).text)
print('    不带 Cookie 再请求 /show → %s' % requests.get(B + '/show', timeout=5).text)
print('  Set-Cookie 是服务器说「把这个存起来」,之后浏览器每次请求同一个站点都会自动带上。')

print('③ Session:数据在服务器,Cookie 里只有钥匙')
s2 = requests.Session()
print('    未登录  /whoami → %s' % s2.get(B + '/whoami', timeout=5).text)
print('    登录    /login?u=小王 → %s' % s2.get(B + '/login?u=小王', timeout=5).text)
print('    再看    /whoami → %s' % s2.get(B + '/whoami', timeout=5).text)
ck = s2.cookies.get('session')
print('    这时浏览器存的 session Cookie:%s…' % ck[:46])
import base64
body = ck.split('.')[0]
print('    第一段 base64 解出来:%s' % base64.urlsafe_b64decode(body + '=' * (-len(body) % 4)).decode())
print('    退出    /logout → %s' % s2.get(B + '/logout', timeout=5).text)
print('    再看    /whoami → %s' % s2.get(B + '/whoami', timeout=5).text)
print('  Flask 的 session 默认把数据「签名后」放在 Cookie 里,所以那串东西看起来像乱码。')
print('  注意是「签名」不是「加密」:内容其实是明文可解的,只是改不了。')

print('④ 改 Cookie 试试')
s3 = requests.Session()
s3.get(B + '/login?u=小王', timeout=5)
good = s3.cookies.get('session')
print('    原样      → %s' % s3.get(B + '/whoami', timeout=5).text)
bad = good[:-1] + ('A' if good[-1] != 'A' else 'B')
print('    改一个字符 → %s' % requests.get(B + '/whoami', timeout=5,
                                        cookies={'session': bad}).text)
print('  签名对不上,Flask 直接当成没有 session,把你当未登录。')

print('⑤ 登录这件事的完整形状')
print('| 步骤 | 做什么 | 容易错在哪 |')
print('|---|---|---|')
for r in (('1 提交账号密码', 'POST 到 /login', '用 GET,密码进了浏览器历史和日志'),
          ('2 服务端核对', '查库、比哈希', '存明文密码;比字符串用 =='),
          ('3 建立会话', 'session[uid] = 用户id', '把整个用户对象塞进 session'),
          ('4 后续请求', '读 session 判断身份', '只在前端判断有没有登录'),
          ('5 退出', '清掉 session', '只删了 Cookie,服务端还认')):
    print('| %s | %s | %s |' % r)
print('  第四条最要命:前端隐藏按钮不叫权限控制。服务端每一处都得自己判一遍。')
srv.shutdown()

实跑输出:

--------------------------------------------------------------------
Cookie、Session 和登录
--------------------------------------------------------------------

  ① HTTP 本身不记事
  --------------------------------------------------------------------
    第一次请求带了几个 Cookie:0
    第二次请求带了几个 Cookie:0
  HTTP 是无状态的:每个请求都是独立的,服务器不记得上一个请求是谁发的。
  不记事,就没法做登录、购物车、浏览记录。
  补这个缺口有两招:Cookie 存在浏览器那边,Session 存在服务器那边。
  两个经常一起用——Cookie 里只放一个编号,真正的数据在服务器上。

  ② Cookie 是服务器让浏览器存的
  --------------------------------------------------------------------
    GET /set  → 服务器回的响应头里有:
      Set-Cookie: fav="\350\236\272\346\240\223"; Expires=Sun, 01 Nov 2026 19:57:27 GMT; Max-Age=3600; Path=/
    带着这个 Cookie 再请求 /show → 你的 fav 是 螺栓
    不带 Cookie 再请求 /show → 你的 fav 是 (空)
  Set-Cookie 是服务器说「把这个存起来」,之后浏览器每次请求同一个站点都会自动带上,直到过期。
  所以 Cookie 在客户手里,用户能看、能改、能删。
  任何「不能让人改」的东西都不能放在 Cookie 里存明文。

  ③ Session:数据在服务器,Cookie 里只有钥匙
  --------------------------------------------------------------------
    未登录  /whoami → 当前用户:未登录
    登录    /login?u=小王 → 已登录:小王
    再看    /whoami → 当前用户:小王
    这时浏览器存的 session Cookie:eyJ1c2VyIjoiXHU1YzBmXHU3MzhiIn0.aueLlw.pNImMrH…
    退出    /logout → 已退出
    再看    /whoami → 当前用户:未登录
  Flask 的 session 默认把数据「签名后」放在 Cookie 里,所以那串东西看起来像乱码。
  服务端能验签,用户改一个字符就失效。
  注意是「签名」不是「加密」:内容其实是明文可解的,只是改不了。
  所以 session 里别放密码、身份证号这类东西。

  ④ 改 Cookie 试试
  --------------------------------------------------------------------
    原样   → 当前用户:小王
    改一个字符 → 当前用户:未登录
  签名对不上,Flask 直接当成没有 session,把你当未登录。
  这就是「Cookie 不可信、但可以用」的实现方式。

  ⑤ 登录这件事的完整形状
  --------------------------------------------------------------------

| 步骤 | 做什么 | 容易错在哪 |
|---|---|---|
| 1 提交账号密码 | POST 到 /login | 用 GET,密码进了浏览器历史和日志 |
| 2 服务端核对 | 查库、比哈希 | 存明文密码;比字符串用 == |
| 3 建立会话 | session['uid'] = 用户id | 把整个用户对象塞进 session |
| 4 后续请求 | 读 session 判断身份 | 只在前端判断有没有登录 |
| 5 退出 | 清掉 session | 只删了 Cookie,服务端还认 |

  第三条:session 里只放用户 id 这种最小信息,每次要用再查库。
  放整个对象进去,用户改个名字你这里还是旧的。
  第四条最要命:前端隐藏按钮不叫权限控制。别人可以直接发请求,服务端每一处都得自己判一遍。

  还有一条教材没展开但必须知道:密码不能存明文,要存哈希(bcrypt、argon2 这类专门给密码用的)。
  普通的 md5、sha1 早就不够了,网上有现成的彩虹表能反查。

第一段那两次请求都是 0 个 Cookie,说明 HTTP 本身不带任何「我是谁」的信息。 第二段那个 Set-Cookie 头是本篇最实在的东西。 注意它同时有 Expires 和 Max-Age,一个给绝对时间一个给相对秒数。 第三段那串 Cookie 分三段:内容、时间戳、签名。 第一段 base64 解出来是明文的 JSON,能看到「小王」两个字就是 Unicode 转义。这就是「签名不是加密」。 第四段最直观:只改最后一个字符,服务端立刻判为未登录。签名对不上就拒收。 这也说明 secret_key 有多重要——谁拿到它,谁就能给任意内容算出合法签名。

30 秒实验

  1. 把演示第三段打印出来的三段拆分,看第二段和第三段各是什么(提示:一个是时间戳,一个是签名)。
  2. 把 app.secret_key 改成另一个字符串,再看第三段那个 Cookie 还能不能用。
  3. 在演示第四段把 Cookie 整个删掉(requests.get 不带 cookies), 看结果跟「改一个字符」有没有区别。

常见误区

  1. 「Cookie 里的东西是加密的,用户看不到。」明文可读。 Flask 的 session 是签名不是加密,base64 一解就能看到内容。
  2. 「前端把管理员按钮藏起来,就安全了。」别人可以直接发请求。服务端每一处都得自己判一遍身份。
  3. 「密码存 md5 就行。」网上有现成的彩虹表能反查常见密码。 要用 bcrypt、argon2 这类故意算得慢的算法,而且每人一个盐。
自测三问(点开对答案) 1. HTTP 是无状态的,这句话是什么意思?为什么它导致需要 Cookie? —— 意思是每个请求都是独立的,服务器不记得上一个请求是谁发的,也不知道两个请求来自同一个人。 这样设计的好处是服务器不用为每个人存状态,能同时服务很多人。 但不记事就没法做登录、购物车,所以要靠 Cookie 让浏览器每次主动带上「我是谁」的凭据。 2. Flask 的 session Cookie 看起来像乱码,那是不是加密了? —— 不是,是签名。内容 base64 解出来就是明文 JSON,只是被签名保护着改不了。 **签名防篡改,不防偷看。** 所以 session 里不能放密码、身份证号这种敏感信息。 3. 为什么密码不能存明文,也不能用 md5? —— 存明文的话,数据库一泄露所有人的密码直接暴露。 md5 这类快速哈希也不行,因为算得快,而且网上有彩虹表能直接反查常见密码。 要用 bcrypt、argon2 这类「故意算得慢」的算法,再给每人加一个随机盐。

第 12 章结束。下一篇进第 13 章:做一个 LINE 聊天机器人。

相关笔记