第6章 6.3 鉴权:JWT 与 Session

🎯 开场 3 分钟:为什么你每次登录都要重新输入密码?

你有没有遇到过这种情况:在网上看中一件衣服,加了购物车,关了浏览器第二天再打开——咦,居然还在!购物车里的东西没丢,网站好像"记住"你了。

但仔细想想,网站是怎么记住你的?

HTTP 有个特点,叫「无状态」——每次请求之间,服务器根本不记得你是谁。你打开淘宝,服务器问:「你是谁?」你输入账号密码登录,服务器说:「哦,你是小明,欢迎!」但下一秒你点开购物车,服务器又问:「你是谁?」——它又忘了你刚才登录过。

这就是鉴权要解决的核心问题:怎么让服务器在多次请求之间「记住」你是谁。

上一章我们学了 HTTPS,知道数据在传输过程中是加密的,别人偷不到。但光有加密还不够,你还得证明「我是我」,这就是鉴权(Authentication)的职责。

本章学完,你能:
- 理解 Session 和 JWT 两种主流鉴权方式的区别
- 用 Python 亲手实现一个带登录功能的 Web 接口
- 知道什么时候该用哪种方式


🧱 基础 25 分钟:Session 和 JWT 到底是个啥?

先说个生活比喻,帮你建立直观感觉。

6.3.1 Session:酒店房卡模式

类比时间⌚

你去酒店住宿,身份证登记后,前台给你一张房卡。你拿着房卡进电梯、开门、回房间——全程只需要这一张卡。退房时卡就失效了。

Session 的原理一模一样:

  1. 你登录网站,输入账号密码
  2. 服务器验证通过,在服务器端创建一条记录(Session)
  3. 服务器给你一张「房卡」(叫 Session ID,是一串随机字符)
  4. 你的浏览器把这张「房卡」存起来(Cookie)
  5. 以后你访问任何页面,浏览器自动把「房卡」递给服务器
  6. 服务器看到「房卡」,就知道「哦,这是小明」

为什么要这么设计?

因为服务器要在多次请求之间认出你,得有个地方存你的信息。Session 就存在服务器内存(或数据库)里。

来,看代码:

# 安装依赖:pip install flask flask-session

from flask import Flask, session, request, jsonify
from flask_session import Session

app = Flask(__name__)
app.secret_key = "小明的秘密钥匙"  # 用来加密 session 数据的密钥
app.config["SESSION_TYPE"] = "filesystem"  # session 存到文件系统

Session(app)

@app.route("/登录", methods=["POST"])
def 登录():
数据 = request.get_json()
用户名 = 数据.get("用户名")
密码 = 数据.get("密码")

# 假设这里有真正的数据库验证
if 用户名 == "小明" and 密码 == "123456":
    session["用户"] = 用户名  # 创建 session
    return jsonify({"消息": "登录成功", "用户名": 用户名})
return jsonify({"消息": "账号或密码错误"}), 401

@app.route("/个人中心")
def 个人中心():
if "用户" in session:
    return jsonify({"消息": f"欢迎回来,{session['用户']}!"})
return jsonify({"消息": "请先登录"}), 401

@app.route("/登出", methods=["POST"])
def 登出():
session.pop("用户", None)  # 删除 session
return jsonify({"消息": "已退出登录"})

if __name__ == "__main__":
app.run(port=5000, debug=True)

运行结果:

# 1. 先登录
curl -X POST http://localhost:5000/登录 \
-H "Content-Type: application/json" \
-d '{"用户名":"小明","密码":"123456"}'
# 返回:{"消息": "登录成功", "用户名": "小明"}

# 2. 访问个人中心(不带 session)
curl http://localhost:5000/个人中心
# 返回:{"消息": "请先登录"}

# 3. 再次登录后再访问个人中心
# (第二次请求时浏览器会自动带上 cookie 中的 session id)
# 返回:{"消息": "欢迎回来,小明!"}

这行在干嘛:
- session["用户"] = 用户名:把用户名存进 session,类似往房卡里写数据
- session.pop("用户", None):销毁 session,类似退房

配图1 - 配图1


6.3.2 JWT:自带身份证的模式

类比时间⌚

你去游乐园,买了一张通票。票上直接印着你的名字、照片、有效日期。你拿着这张票,一天内随便进出哪个项目,检票员扫一眼就知道「这是小明买的票,还没过期,可以进」。

不需要游乐园前台记住你是谁——所有信息都在票上写着。

JWT(JSON Web Token)就是这个原理:

  1. 你登录网站,输入账号密码
  2. 服务器验证通过,生成一个「通票」(JWT)
  3. 这个「通票」里包含你的信息(用户名、过期时间等),并且服务器用私钥签名防止伪造
  4. 服务器把「通票」发给你
  5. 你的浏览器存起来(通常存 localStorage 或 cookie)
  6. 以后你访问任何页面,请求头里带着这张「通票」
  7. 服务器验证签名就知道「这票是真的,是小明的,没过期」

和 Session 的核心区别:

Session JWT
存储位置 服务器端 客户端(浏览器)
状态 有状态(服务器要存数据) 无状态(服务器不存数据)
扩展性 多服务器集群时要共享 session 天生适合分布式
安全性 泄露了 session id 别人就能用 泄露了 token 别人就能用(但有过期时间)

来,看代码:

# 安装依赖:pip install pyjwt

import jwt
import datetime

# 这是服务器的私钥,只有服务器知道
秘钥 = "小明的超级秘密钥匙"

def 生成Token(用户名):
"""登录成功后,生成 JWT"""
过期时间 = datetime.datetime.utcnow() + datetime.timedelta(hours=24)  # 24小时后过期
载荷 = {
    "用户名": 用户名,
    "exp": 过期时间  # expiration time
}
# jwt.encode(载荷, 秘钥, 算法)
token = jwt.encode(载荷, 秘钥, algorithm="HS256")
return token

def 验证Token(token):
"""验证 JWT 是否有效"""
try:
    载荷 = jwt.decode(token, 秘钥, algorithms=["HS256"])
    return {"有效": True, "用户名": 载荷.get("用户名")}
except jwt.ExpiredSignatureError:
    return {"有效": False, "原因": "Token已过期"}
except jwt.InvalidTokenError:
    return {"有效": False, "原因": "Token无效"}

# 模拟登录
token = 生成Token("小明")
print(f"生成的Token: {token}")

# 模拟验证
结果 = 验证Token(token)
print(f"验证结果: {结果}")

运行结果:

生成的Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IuW8o-ixNIiwiZXhwIjoxNzE2MjM0ODAwfQ.7d2v1kHqQJZxY8gR3hQJ6mN9x1yT5wE4cD8sL2pO9kM

验证结果: {'有效': True, '用户名': '小明'}

这行在干嘛:
- jwt.encode(载荷, 秘钥, algorithm="HS256"):用私钥签名,生成 Token
- jwt.decode(token, 秘钥, algorithms=["HS256"]):验证签名和解码

Token 长啥样?

JWT 的格式是 xxxxx.yyyyy.zzzzz,由三部分组成:
- xxxxx(Header):告诉别人这是什么算法生成的
- yyyyy(Payload):存的用户信息
- zzzzz(Signature):签名,确保没人篡改过

你可以在 https://jwt.io/ 这个网站在线解码看看。

配图2 - 配图2


6.3.3 什么时候用哪种?

用 Session 的场景:
- 小型网站,用户量不大
- 需要强制让某个用户下线(删掉服务端 session 就行)
- Token 不想泄露在 URL 里(Session 存在 Cookie 中更安全)

用 JWT 的场景:
- 分布式系统,多个服务器要共享认证状态
- 移动端 App(移动端不太方便用 Cookie)
- 想让某个接口可以被第三方调用(OpenID Connect 就是基于 JWT 的)


🔥 实战 35 分钟:3 个递进的小项目

项目 1(5 分钟):Flask 实现带 Session 的登录

先来个简单的,用 Flask 实现最基础的登录功能。

# 文件名: login_session.py
# 运行: python login_session.py

from flask import Flask, session, request, jsonify

app = Flask(__name__)
app.secret_key = "very_secret_key_123"  # 换个复杂的密钥

# 模拟数据库:用户名 -> 密码
用户数据库 = {
"小明": "password123",
"小红": "ILovePython",
"老王": "admin888"
}

@app.route("/login", methods=["POST"])
def login():
data = request.get_json()
username = data.get("username")
password = data.get("password")

if username in 用户数据库 and 用户数据库[username] == password:
    session["user"] = username
    return jsonify({"success": True, "message": f"欢迎 {username}!"})
return jsonify({"success": False, "message": "账号或密码错误"}), 401

@app.route("/profile")
def profile():
if "user" in session:
    return jsonify({"success": True, "user": session["user"]})
return jsonify({"success": False, "message": "请先登录"}), 401

@app.route("/logout", methods=["POST"])
def logout():
session.pop("user", None)
return jsonify({"success": True, "message": "已退出"})

if __name__ == "__main__":
print("登录接口: POST /login  参数: username, password")
print("个人中心: GET /profile")
print("退出登录: POST /logout")
app.run(port=5000, debug=True)

测试方法:

# 1. 登录
curl -c cookies.txt -X POST http://localhost:5000/login \
-H "Content-Type: application/json" \
-d '{"username":"小明","password":"password123"}'

# 2. 带着 cookie 访问个人中心
curl -b cookies.txt http://localhost:5000/profile

# 3. 退出
curl -b cookies.txt -X POST http://localhost:5000/logout

预期输出:

# 登录
{"success": true, "message": "欢迎 小明!"}

# 访问个人中心
{"success": true, "user": "小明"}

# 退出后再访问
{"success": false, "message": "请先登录"}

一句话解释: Session 像房卡,服务器帮你记着你是谁,你只需要带着卡就行。


项目 2(15 分钟):Flask + JWT 实现带过期时间的 API 鉴权

这回换 JWT,模拟一个任务管理 API。

# 文件名: task_api_jwt.py
# 运行: python task_api_jwt.py

from flask import Flask, request, jsonify
import jwt
import datetime

app = Flask(__name__)
秘钥 = "jwt_super_secret_key_456"

# 模拟数据库
用户库 = {"小明": "123", "小红": "456"}
任务库 = {
"小明": [{"id": 1, "内容": "写周报", "完成": False}],
"小红": [{"id": 2, "内容": "开会", "完成": True}]
}

def 生成Token(username):
payload = {
    "username": username,
    "exp": datetime.datetime.utcnow() + datetime.timedelta(hours=2)  # 2小时过期
}
return jwt.encode(payload, 秘钥, algorithm="HS256")

def 验证Token(token):
try:
    payload = jwt.decode(token, 秘钥, algorithms=["HS256"])
    return payload["username"]
except:
    return None

def 需要认证(f):
"""装饰器:检查请求头里有没有有效的 Token"""
def wrapper(*args, **kwargs):
    auth_header = request.headers.get("Authorization")
    if not auth_header or not auth_header.startswith("Bearer "):
        return jsonify({"error": "缺少Token"}), 401

    token = auth_header.split(" ")[1]
    username = 验证Token(token)
    if not username:
        return jsonify({"error": "Token无效或已过期"}), 401

    return f(username, *args, **kwargs)
wrapper.__name__ = f.__name__
return wrapper

@app.route("/login", methods=["POST"])
def login():
data = request.get_json()
username = data.get("username")
password = data.get("password")

if username in 用户库 and 用户库[username] == password:
    token = 生成Token(username)
    return jsonify({"token": token})
return jsonify({"error": "账号或密码错误"}), 401

@app.route("/tasks", methods=["GET"])
@需要认证
def 获取任务(username):
tasks = 任务库.get(username, [])
return jsonify({"username": username, "tasks": tasks})

@app.route("/tasks", methods=["POST"])
@需要认证
def 添加任务(username):
data = request.get_json()
new_task = {
    "id": len(任务库.get(username, [])) + 1,
    "内容": data.get("内容"),
    "完成": False
}
if username not in 任务库:
    任务库[username] = []
任务库[username].append(new_task)
return jsonify({"message": "任务添加成功", "task": new_task})

@app.route("/tasks/<int:task_id>", methods=["PUT"])
@需要认证
def 更新任务(username, task_id):
data = request.get_json()
tasks = 任务库.get(username, [])
for task in tasks:
    if task["id"] == task_id:
        task["完成"] = data.get("完成", task["完成"])
        return jsonify({"message": "任务更新成功", "task": task})
return jsonify({"error": "任务不存在"}), 404

if __name__ == "__main__":
print("登录: POST /login  (username, password)")
print("获取任务: GET /tasks  (Header: Authorization: Bearer <token>)")
print("添加任务: POST /tasks  (内容)")
print("更新任务: PUT /tasks/<id>  (完成: true/false)")
app.run(port=5001, debug=True)

测试:

# 1. 登录获取 Token
TOKEN=$(curl -s -X POST http://localhost:5001/login \
-H "Content-Type: application/json" \
-d '{"username":"小明","password":"123"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
echo "获取到的Token: $TOKEN"

# 2. 用 Token 获取任务
curl -H "Authorization: Bearer $TOKEN" http://localhost:5001/tasks

# 3. 添加新任务
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"内容":"学习JWT"}' \
http://localhost:5001/tasks

# 4. 用过期或假的 Token 试试
curl -H "Authorization: Bearer fake_token" http://localhost:5001/tasks

预期输出:

# 登录
{"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}

# 获取任务
{"username": "小明", "tasks": [{"id": 1, "内容": "写周报", "完成": false}]}

# 添加任务
{"message": "任务添加成功", "task": {"id": 2, "内容": "学习JWT", "完成": false}}

# 用假 Token
{"error": "Token无效或已过期"}

一句话解释: Token 过期了就得重新登录,这就避免了「永久有效」的 token 被盗用后别人能一直冒用的问题。


项目 3(15 分钟):组合做个「轻量级用户系统」

这回综合一下,做一个能注册、能登录、能查看个人信息的完整小工具。

# 文件名: mini_user_system.py
# 运行: python mini_user_system.py

import json
import jwt
import datetime
import hashlib
import os

# JWT 配置
秘钥 = "mini_system_secret_key_789"
Token过期小时 = 24

# 用户数据文件
用户文件 = "users.json"

def 加载用户():
"""从文件加载用户数据"""
if os.path.exists(用户文件):
    with open(用户文件, "r", encoding="utf-8") as f:
        return json.load(f)
return {}

def 保存用户(users):
"""保存用户数据到文件"""
with open(用户文件, "w", encoding="utf-8") as f:
    json.dump(users, f, ensure_ascii=False, indent=2)

def 加密密码(password):
"""简单密码加密(生产环境用 bcrypt)"""
return hashlib.sha256(password.encode()).hexdigest()

def 生成Token(username):
payload = {
    "username": username,
    "exp": datetime.datetime.utcnow() + datetime.timedelta(hours=Token过期小时)
}
return jwt.encode(payload, 秘钥, algorithm="HS256")

def 验证Token(token):
try:
    payload = jwt.decode(token, 秘钥, algorithms=["HS256"])
    return payload["username"]
except:
    return None

class 用户系统:
def __init__(self):
    self.users = 加载用户()

def 注册(self, username, password):
    if username in self.users:
        return False, "用户名已存在"
    self.users[username] = {
        "password": 加密密码(password),
        "created": datetime.datetime.now().isoformat()
    }
    保存用户(self.users)
    return True, "注册成功"

def 登录(self, username, password):
    if username not in self.users:
        return None, "用户不存在"
    if self.users[username]["password"] != 加密密码(password):
        return None, "密码错误"
    token = 生成Token(username)
    return token, "登录成功"

def 验证请求(self, token):
    username = 验证Token(token)
    if username and username in self.users:
        return username
    return None

def 获取信息(self, username):
    if username in self.users:
        return {
            "username": username,
            "created": self.users[username]["created"]
        }
    return None

def main():
系统 = 用户系统()

print("=" * 40)
print("  轻量级用户系统 v1.0")
print("=" * 40)
print()

while True:
    print("1. 注册  2. 登录  3. 查看个人信息  4. 退出")
    choice = input("请选择操作: ").strip()

    if choice == "1":
        username = input("用户名: ").strip()
        password = input("密码: ").strip()
        success, msg = 系统.注册(username, password)
        print(f"结果: {msg}")

    elif choice == "2":
        username = input("用户名: ").strip()
        password = input("密码: ").strip()
        token, msg = 系统.登录(username, password)
        if token:
            print(f"结果: {msg}")
            print(f"你的Token: {token[:50]}...")
        else:
            print(f"结果: {msg}")

    elif choice == "3":
        token = input("请粘贴Token: ").strip()
        username = 系统.验证请求(token)
        if username:
            info = 系统.获取信息(username)
            print(f"结果: 欢迎 {info['username']},注册于 {info['created']}")
        else:
            print("结果: Token无效或已过期")

    elif choice == "4":
        print("再见!")
        break

    print()

if __name__ == "__main__":
main()

运行效果:

========================================
轻量级用户系统 v1.0
========================================

1. 注册  2. 登录  3. 查看个人信息  4. 退出
请选择操作: 1
用户名: 小明
密码: abc123
结果: 注册成功

请选择操作: 2
用户名: 小明
密码: abc123
结果: 登录成功
你的Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IuW8o...

请选择操作: 3
请粘贴Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
结果: 欢迎 小明,注册于 2024-01-15T10:30:00

请选择操作: 4
再见!

一句话解释: 把用户数据存到 JSON 文件里,这样程序重启后用户数据还在,实现了「持久化」。


💪 进阶 20 分钟:常见坑 + 性能小贴士

坑 1:Token 存在 localStorage 里会被 XSS 攻击偷走

❌ 错误做法:

// 前端代码(不要这样写!)
localStorage.setItem("token", response.data.token);

✅ 正确做法:

// 把 Token 存在 HttpOnly Cookie 里,只有同源的请求才会自动带上
// 后端设置:res.cookie("token", token, { httpOnly: true });

解释: HttpOnly 的 Cookie JavaScript 读不到,XSS 攻击也就偷不走。


坑 2:Session 的 secret_key 太简单

❌ 错误做法:

app.secret_key = "123"  # 太简单,容易被猜到

✅ 正确做法:

import secrets
app.secret_key = secrets.token_hex(32)  # 生成32字节的随机密钥

坑 3:JWT 过期时间设太长

❌ 错误做法:

payload = {
"username": username,
"exp": datetime.datetime.utcnow() + datetime.timedelta(days=365)  # 一年?太久了!
}

✅ 正确做法:

# _ACCESS_TOKEN 到期时间设短一点(比如15分钟)
# _REFRESH_TOKEN 到期时间长一点(比如7天)
# access token 过期了,用 refresh token 换一个新的

坑 4:密码用明文存储

❌ 错误做法:

if user.password == password:  # 明文密码比对
return "登录成功"

✅ 正确做法:

# 用 bcrypt 或 argon2 加密
import bcrypt
if bcrypt.checkpw(password.encode(), user.password_hash.encode()):
return "登录成功"

坑 5:Session 没有设置过期

❌ 错误做法:

session["user"] = username  # 没有设置过期时间

✅ 正确做法:

from datetime import timedelta
session.permanent = True
app.permanent_session_lifetime = timedelta(hours=2)

性能小贴士:Session 存储用 Redis

如果用户量很大,文件存储的 Session 会变慢。把Session 存到 Redis 里:

# pip install redis flask-session
app.config["SESSION_TYPE"] = "redis"
app.config["SESSION_PERMANENT"] = False
app.config["SESSION_USE_SIGNER"] = True
app.config["SESSION_KEY_PREFIX"] = "session:"
app.config["SESSION_REDIS"] = redis.Redis(host="localhost", port=6379)

调试技巧:用 print 大法

def 验证Token(token):
print(f"[DEBUG] 收到的Token: {token[:20]}...")  # 打印前20个字符
try:
    payload = jwt.decode(token, 秘钥, algorithms=["HS256"])
    print(f"[DEBUG] 解码成功: {payload}")
    return payload["username"]
except jwt.ExpiredSignatureError:
    print("[DEBUG] Token已过期")
    return None
except jwt.InvalidTokenError as e:
    print(f"[DEBUG] Token无效: {e}")
    return None

✏️ 练习题

练习 1(2 分钟):改 Token 过期时间
- 输入:把项目 2 的 Token 过期时间从 2 小时改成 30 分钟
- 预期输出:代码能正常运行,Token 30 分钟后过期
- 提示:找 timedelta(hours=2) 改成 minutes=30

练习 2(2 分钟):加一个「修改密码」功能
- 输入:在项目 1 基础上加一个新路由 /change_password,需要先验证旧密码
- 预期输出:POST 请求能修改密码
- 提示:新建一个路由,用 session.pop 清掉旧 session 后要求重新登录

练习 3(3 分钟):给任务 API 加个「统计」功能
- 输入:用项目 2 的数据,统计每个人的任务总数和已完成数
- 预期输出:{"username": "小明", "total": 5, "done": 2}
- 提示:用列表推导式或循环统计 完成==True 的任务数

练习 4(3 分钟):把项目 2 的 JWT 改成项目 1 的 Session 实现
- 输入:把项目 2 的 Token 验证改成 Session 验证
- 预期输出:登录后访问 /tasks 能正常返回数据
- 提示:把 Authorization: Bearer <token> 改成 Cookie 里的 session

练习 5(5 分钟):Token 被篡改后会怎样?
- 输入:手动改一下 JWT 的 payload 部分(比如把 "小明" 改成 "管理员"),然后验证
- 预期输出:验证失败,报 InvalidSignatureError
- 提示:JWT 的第三段是签名,改了 payload 但没改签名,验证会失败


作业:做一个「带 Token 刷新功能的记事本」

需求描述:
用 Flask 实现一个命令行记事本,用户的记事内容存在 JSON 文件里,每个用户只能看到自己的内容。

功能点:
1. 用户注册/登录:注册时密码用 bcrypt 加密,登录后返回 JWT
2. 记事本 CRUD:登录后可以查看、添加、删除、标记完成记事
3. Token 刷新:access token 5 分钟过期,但可以用 refresh token 换新的

加分项:
1. 用 Redis 存 refresh token(而不是放内存里)
2. 支持多设备登录,但可以强制某设备下线

验收标准:
- 注册登录流程能跑通
- 记事本能正常增删改查
- Token 过期后能用 refresh token 获取新 token
- 代码有适当注释


📚 总结 + 资源

本文学了 3 件事:
1. Session 像房卡,服务器帮你记着谁登录了,你带着 Session ID 就行
2. JWT 像通票,所有信息都在票上,服务器只验证签名真假
3. 密码要加密存储,Token 要设过期时间,这是基本的安全规范

延伸学习资源:

  1. PyJWT 官方文档 - 权威参考
  2. 《Web 安全设计之道》第三章 - 讲了很多实际的安全漏洞案例
  3. Flask 官方文档 - Session - 看看 Flask 原生 Session 怎么用

互动钩子:
你在公司用过 Session 还是 JWT?遇到过什么坑?评论区聊聊,老粉优先回复!


下一章我们要聊的话题更刺激:XSS、CSRF、SQL 注入——这些名字听起来吓人,但理解了原理之后,你会发现它们都是「纸老虎」。下一章见!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。