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 秒实验
- 把演示第三段打印出来的三段拆分,看第二段和第三段各是什么(提示:一个是时间戳,一个是签名)。
- 把
app.secret_key改成另一个字符串,再看第三段那个 Cookie 还能不能用。 - 在演示第四段把 Cookie 整个删掉(
requests.get不带 cookies), 看结果跟「改一个字符」有没有区别。
常见误区
- 「Cookie 里的东西是加密的,用户看不到。」明文可读。 Flask 的 session 是签名不是加密,base64 一解就能看到内容。
- 「前端把管理员按钮藏起来,就安全了。」别人可以直接发请求。服务端每一处都得自己判一遍身份。
- 「密码存 md5 就行。」网上有现成的彩虹表能反查常见密码。 要用 bcrypt、argon2 这类故意算得慢的算法,而且每人一个盐。
自测三问(点开对答案)
1. HTTP 是无状态的,这句话是什么意思?为什么它导致需要 Cookie? —— 意思是每个请求都是独立的,服务器不记得上一个请求是谁发的,也不知道两个请求来自同一个人。 这样设计的好处是服务器不用为每个人存状态,能同时服务很多人。 但不记事就没法做登录、购物车,所以要靠 Cookie 让浏览器每次主动带上「我是谁」的凭据。 2. Flask 的 session Cookie 看起来像乱码,那是不是加密了? —— 不是,是签名。内容 base64 解出来就是明文 JSON,只是被签名保护着改不了。 **签名防篡改,不防偷看。** 所以 session 里不能放密码、身份证号这种敏感信息。 3. 为什么密码不能存明文,也不能用 md5? —— 存明文的话,数据库一泄露所有人的密码直接暴露。 md5 这类快速哈希也不行,因为算得快,而且网上有彩虹表能直接反查常见密码。 要用 bcrypt、argon2 这类「故意算得慢」的算法,再给每人加一个随机盐。第 12 章结束。下一篇进第 13 章:做一个 LINE 聊天机器人。
- ☐ (本篇完,下一篇 13.1 LINE Bot 架构与 Echo Bot)