码上问学问代码之理,学编程之道

第 8 章 · 认证与鉴权(JWT)

前面的接口谁都能调,用户的收藏、书评、个人中心却没有「主人」——这就是本章要解决的缺口。学完这一章,你能让「注册 → 登录 → 拿到 token → 带着 token 访问受保护接口」这条链路真正跑通:密码哈希入库、登录签发 JWT、装饰器校验身份,最后在 Postman 里一条条验证。

概念不熟?如果你还不清楚 Cookie、Session、JWT 的区别,建议先读《Flask 基础概念图解》。看懂「会员卡、存包柜、通行证」三个类比,本章的 JWT 签发与校验会更通透。

为什么密码绝不能明文存

先想清楚后果
密码绝不存明文:哈希是单向的 明文 123456 危险! 哈希函数 werkzeug 自动加盐 哈希值 $2b$12$... 不可逆,存库的就是它 ✗ 无法反推明文 验证时:把登录输入的密码再哈希一次,与库里存的哈希比对 —— 相同才算对。 这样即使数据库泄露,攻击者也拿不到任何人明文密码。

哈希是单向函数:明文 → 哈希值容易,哈希值 → 明文几乎不可能。所以库里只存哈希;登录时把输入再哈希一次和库里比对,谁也看不到原始密码。

先讲为什么把密码当普通字段写进 user.password,功能上「能登录」,但一旦数据库被拖走(泄露、备份外流、外包人员拿到),攻击者拿到的是一张明码标价的密码表。更糟的是:很多人在不同网站用同一个密码,你泄露的不只是自己的站,而是用户在其他平台的账号。
所以行业铁律是:数据库里永远不存密码本身,只存它的「哈希值」。
避坑不要自己「发明」加密方案。常见错误有三种:① 存明文;② 用 md5/sha1 直接算一次(这两种算法早已被撞库破解,且不加盐);③ 用可逆的对称加密(能加密就能解密,等于没保护)。正确做法是直接用成熟库提供的带盐哈希函数,下一节就用 Flask 自带的 Werkzeug。

用 werkzeug 做密码哈希

生成哈希 & 校验哈希
动手做不用额外装包——werkzeug.security 是 Flask 的依赖,直接可用。它提供两个函数:generate_password_hash() 生成哈希、check_password_hash() 校验。先在一个脚本里看清楚它们的行为。
hash_demo.pypython
from werkzeug.security import generate_password_hash, check_password_hash

h1 = generate_password_hash("123456")
h2 = generate_password_hash("123456")   # 再算一次,结果和 h1 不同

print(h1)
print("两次相同吗:", h1 == h2)
print("校验正确密码:", check_password_hash(h1, "123456"))
print("校验错误密码:", check_password_hash(h1, "654321"))
运行输出Python 运行结果
运行结果: $ python hash_demo.py scrypt:32768:8:1$Kd7pQm2RxT5sWb1n$1f0c9a...(后略) 两次相同吗: False 校验正确密码: True 校验错误密码: False # 说明:每次运行生成的哈希都不一样,因为盐(salt)是随机的
讲透
  • 哈希串里自带了一切:以 scrypt:32768:8:1$盐$哈希 为例,冒号前是算法与参数,第一个 $ 后是随机盐,第二个 $ 后是真正的摘要。校验时不需要你保存盐,函数会从这串里自己解析出来。
  • 默认算法会随版本升级:Werkzeug 3.x 默认用 scrypt,更早版本默认 pbkdf2:sha256。想固定算法可显式传参:generate_password_hash(pwd, method="pbkdf2:sha256")。两种都安全,关键是别用 md5/sha1 裸哈希。
  • 为什么「故意算得慢」:scrypt / pbkdf2 这类算法被设计成计算成本高。正常业务一次校验几十毫秒无感,但攻击者想暴力穷举几亿个候选密码时,成本会被放大到无法承受。
  • 字段留 255 位是有原因的:哈希串通常上百个字符,写 VARCHAR(50) 会直接被截断,导致以后永远登录失败。这就是 user.password 定义成 VARCHAR(255) 的意义。
避坑哈希是不可逆的,所以「忘记密码」永远不能把原密码发回给用户,只能走「重置密码」流程(生成一次性 token → 用户设新密码)。hiwen.sql 里的种子用户密码是占位符 PUT_HASH_HERE,无法登录是正常的——你要用上面的命令重新生成哈希再替换,或者干脆走「注册」接口创建新用户。

注册接口:校验 + 写库

POST /api/user/register
注册接口:校验 + 写库的完整流程 ① 收到 { username, password } ② 参数校验:非空 · 长度 · 格式 ③ 用户名查重(已存在则报错) ④ 密码哈希 → 写库 → 返回成功 明文密码绝不落库 任一步失败 直接返回 { code:4xx, msg:"原因" } 不继续往下走, 也不写库

注册接口的完整链路:收参数 → 校验格式 → 查重 → 密码哈希后写库。任一步失败都直接返回错误码,绝不往下走、绝不把明文写进库。

动手做对照《API 接口文档》2.1 节实现注册。核心三件事:参数校验(用户名规则、密码长度)→ 唯一性校验(用户名/邮箱不能重复)→ 哈希后写库。注意每个分支都返回统一的 {code, msg, data}。
apps/user/views.pypython
import re
from flask import Blueprint, request, jsonify
from werkzeug.security import generate_password_hash
from ..models import db, User

user_bp = Blueprint("user", __name__, url_prefix="/api/user")

# 用户名:3-20 位,字母 / 数字 / 下划线(对齐接口文档 2.1)
USERNAME_RE = re.compile(r"^\w{3,20}$")


@user_bp.post("/register")
def register():
    data = request.get_json(silent=True) or {}
    username = (data.get("username") or "").strip()
    password = data.get("password") or ""
    nickname = (data.get("nickname") or "").strip() or username
    email = (data.get("email") or "").strip() or None

    # ① 参数校验:不通过就返回 400 + 可读提示
    if not USERNAME_RE.match(username):
        return jsonify(code=400, msg="用户名需 3-20 位字母、数字或下划线", data=None)
    if not (6 <= len(password) <= 20):
        return jsonify(code=400, msg="密码长度需 6-20 位", data=None)
    if email and ("@" not in email or "." not in email.split("@")[-1]):
        return jsonify(code=400, msg="邮箱格式不正确", data=None)

    # ② 唯一性校验:用户名、邮箱都不能重复
    if User.query.filter_by(username=username).first():
        return jsonify(code=400, msg="用户名已存在", data=None)
    if email and User.query.filter_by(email=email).first():
        return jsonify(code=400, msg="邮箱已被注册", data=None)

    # ③ 密码哈希后写库——这一行是整章的底线,绝不能存明文
    user = User(
        username=username,
        password=generate_password_hash(password),
        nickname=nickname,
        email=email,
    )
    db.session.add(user)
    db.session.commit()

    return jsonify(code=200, msg="注册成功", data={"user_id": user.id})
运行输出Python 运行结果
运行结果: $ curl -X POST http://127.0.0.1:5000/api/user/register \ -H "Content-Type: application/json" \ -d '{"username":"zhangsan","password":"abc12345","nickname":"张三","email":"zhangsan@example.com"}' { "code": 200, "msg": "注册成功", "data": { "user_id": 3 } }
重复注册的响应.jsonjson
{
  "code": 400,
  "msg": "用户名已存在",
  "data": null
}
响应内容接口返回数据
运行结果: 第二次用同一个 username 请求,返回上面的 JSON; 数据库里 user.password 存的是 scrypt 哈希串,不是明文
讲透
  • 校验要「前置」:先进业务逻辑再报错的写法会写出脏数据。所以按「参数 → 唯一性 → 写库」的顺序层层设闸,任何一关不过就提前 return。
  • 唯一性校验的兜底:上面的 filter_by 查询能挡住绝大多数重复请求,但高并发下两个请求可能同时通过检查——真正的兜底是数据库的 UNIQUE KEY uk_user_username。捕获 IntegrityError 再返回 400 是更稳的写法。
  • nickname 不填就用 username:接口文档里写得很清楚「不填则默认取 username」,用 or 一行就能表达。
  • 响应里绝不能出现 password:接口文档第八章第 8 条明确要求「不返回敏感字段」。注册只回一个 user_id 就够前端用了。

登录接口:核对密码并签发 JWT

POST /api/user/login
登录接口:核对密码并签发 JWT ① 收到 { username, password } ② 按 username 查库,取出密码哈希 ③ check_password_hash 比对 ④ 签发 JWT → 返回 { token } 前端保存,后续请求带上 失败分支 用户不存在 → 401 密码不对 → 401 提示语统一, 不泄露用户是否存在

登录链路:查库取出哈希 → 比对密码 → 签发 JWT 返回。失败时统一返回 401,提示语不要区分「用户不存在」和「密码错误」,避免泄露账号是否存在。

先讲为什么注册完,用户要「证明自己是自己」。但 HTTP 是无状态的——服务器不记得上一次请求是谁发的。传统做法是发一张 Session ID 存在服务端,每次请求带 Cookie 来对;而前后端分离项目里前端是 Vue 应用,更常用 JWT:服务器把「你是谁」签成一张带签名的通行证交给前端,前端之后每次请求都带着它。
登录接口的职责就是:验明正身,然后把这张通行证发出去。
动手做先初始化 Flask-JWT-Extended(4.x 版本),把密钥和有效期配好,顺便统一 401 的提示语——这样「没带 token」和「token 过期」的错误文案就和接口文档第七章一致了。
apps/__init__.pypython
from datetime import timedelta
from flask import Flask, jsonify
from flask_jwt_extended import JWTManager

jwt = JWTManager()


def create_app():
    app = Flask(__name__)
    app.config["SECRET_KEY"] = "dev-secret-change-me"
    # JWT 的签名密钥:生产环境务必从环境变量读,不要写死在代码里
    app.config["JWT_SECRET_KEY"] = "jwt-secret-change-me"
    app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=2)  # 默认 15 分钟,这里放宽

    jwt.init_app(app)

    # 统一 401 响应:注意这里不传 status code,HTTP 状态码仍是 200(对齐接口文档)
    @jwt.unauthorized_loader
    def no_token(reason):
        return jsonify(code=401, msg="请先登录", data=None)

    @jwt.expired_token_loader
    def token_expired(header, payload):
        return jsonify(code=401, msg="登录已过期,请重新登录", data=None)

    from .user.views import user_bp
    app.register_blueprint(user_bp)
    return app
运行输出Python 运行结果
运行结果: $ flask --app apps run --debug * Serving Flask app 'apps' * Debug mode: on * Running on http://127.0.0.1:5000 # jwt 初始化成功,无报错即表示密钥与有效期已生效
动手做接着写登录视图。关键两步:check_password_hash() 核对密码、create_access_token() 签发 token。
apps/user/views.py(续)python
from werkzeug.security import check_password_hash
from flask_jwt_extended import create_access_token


@user_bp.post("/login")
def login():
    data = request.get_json(silent=True) or {}
    username = (data.get("username") or "").strip()
    password = data.get("password") or ""
    if not username or not password:
        return jsonify(code=400, msg="用户名和密码不能为空", data=None)

    user = User.query.filter_by(username=username).first()

    # 用户不存在 与 密码错误 走同一句提示,避免暴露「这个账号存不存在」
    if user is None or not check_password_hash(user.password, password):
        return jsonify(code=400, msg="用户名或密码错误", data=None)
    if user.status != 1:
        return jsonify(code=403, msg="账号已被禁用", data=None)

    # 身份标识必须是字符串:Flask-JWT-Extended 4.6+ 要求 sub 声明为字符串
    token = create_access_token(identity=str(user.id))

    return jsonify(code=200, msg="登录成功", data={
        "token": token,
        "user": {
            "id": user.id,
            "username": user.username,
            "nickname": user.nickname,
            "avatar": user.avatar,
        },
    })
运行输出Python 运行结果
运行结果: $ curl -X POST http://127.0.0.1:5000/api/user/login \ -H "Content-Type: application/json" \ -d '{"username":"zhangsan","password":"abc12345"}' { "code": 200, "msg": "登录成功", "data": { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIzIiwiZXhwIjoxNzk5...", "user": { "id": 3, "username": "zhangsan", "nickname": "张三", "avatar": "" } } }
讲透
  • 「用户名或密码错误」是刻意的模糊:如果分开提示「用户不存在」,攻击者就能拿一个字典批量试出你站里有哪些账号(用户枚举)。接口文档 2.2 的备注专门强调了这一点。
  • identity=str(user.id) 里的 str() 不能省:JWT 标准规定 sub(subject)必须是字符串,4.6 以后版本会直接报错。取出时再 int() 转回来,这个细节下一节会用到。
  • 登录响应里带上 user 基本信息:前端登录成功后要立刻渲染头像和昵称,顺手返回省一次请求。
  • JWT 的密钥是命门:任何拿到 JWT_SECRET_KEY 的人都能伪造任意用户身份。它必须足够长、随机,且只放在环境变量里,不能进 Git。
避坑不要把 SECRET_KEY 和 JWT_SECRET_KEY 混为一谈后随便填个 "123"。短期看不出问题,但只要密钥可猜,别人就能自己「签发」一张 identity=1 的 token 冒充管理员。生成方式:python -c "import secrets; print(secrets.token_hex(32))"。

JWT 到底是什么

三段式:header.payload.signature
JWT = 头 . 载荷 . 签名 ① header 头 { "alg":"HS256", "typ":"JWT" } ② payload 载荷 { "user_id":7, "exp":... } ③ signature 签名 HMAC(前两段+密钥) · · 签名由「前两段 + 只有服务器知道的密钥」算出; 服务端只验签名就能信你,不用查库——这就是「无状态」。

JWT 由三段用「.」连接:头(算法)、载荷(用户身份等声明)、签名(防篡改)。因为签名用密钥生成,服务器验签即可确认 token 没被篡改、是你签发的,无需再查数据库。

先讲为什么你得明白手里这张 token 里装了什么,才知道哪些信息可以放、哪些绝对不能放。很多新手以为 JWT 是「加密过的秘密」——恰恰相反:JWT 的载荷是明文可读的,它保证的是「没被篡改」,而不是「看不见」。这个认知差,正是泄露事故的源头。
讲透:一张 token 长什么样
  • 三段用 . 分隔:header.payload.signature。前两段是 Base64URL 编码的 JSON,第三段是用密钥算出来的签名。
  • header 说明算法和类型;payload 是真正的数据;signature 是防伪标签——服务器拿到 token 后用密钥重算一遍,对不上就说明被改过。
  • Base64 不是加密:任何人复制中间那段去解码,都能看到里面的内容。所以 payload 里只放无敏感意义的标识,比如 user_id。
token 三段解码示意text
# 原始 token(示意,实际更长)
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiIzIiwiZXhwIjoxNzk5MDAwMDAwfQ . 3fQ9x...

# 第 1 段 header(Base64 解码后)
{ "alg": "HS256", "typ": "JWT" }

# 第 2 段 payload(Base64 解码后)—— 明文可读!
{ "sub": "3", "exp": 1799000000 }
效果预览运行结果
运行结果: sub = 3 → 当前用户 id(我们登录时用 str(user.id) 写入) exp = 1799000000 → 过期时间戳,超时后校验失败 # 结论:payload 里放的 user_id 是「明文可读」的, # 所以绝不能在 payload 里塞密码、手机号、身份证号等信息
讲透:为什么 payload 只放 user_id
  • 够用就好:服务器拿到 user_id 后,可以自己去数据库查这个用户的完整信息。把昵称、角色这些也塞进 token,会让 token 变长且一旦改了资料就得重新登录。
  • 容量有上限:token 放在请求头里,越长每个请求的开销越大。JWT 应该「瘦」。
  • 敏感信息一旦签进去就收不回来:token 会被前端存进 localStorage、被日志、被代理记录;只要它没过期,里面的内容就一直暴露着。
  • 正确姿势:payload 只留「你是谁」(sub/user_id)+ 标准字段(exp 过期时间、iat 签发时间)。其余一律现查数据库。

校验 token:jwt_required 与身份提取

@jwt_required() + get_jwt_identity()
动手做给需要登录的接口加上 @jwt_required() 装饰器,它负责从请求头 Authorization: Bearer <token> 里取出并校验 token;再用 get_jwt_identity() 把登录时写进去的 user_id 取回来。下面实现接口文档 2.3 的「获取个人信息」。
apps/user/views.py(续)python
from flask_jwt_extended import jwt_required, get_jwt_identity


@user_bp.get("/profile")
@jwt_required()                          # 没带合法 token 会直接在这里被拦下
def profile():
    user_id = int(get_jwt_identity())    # 取回登录时写入的 user_id(字符串 → int)
    user = db.session.get(User, user_id)
    if user is None:
        return jsonify(code=404, msg="用户不存在", data=None)

    return jsonify(code=200, msg="success", data={
        "id": user.id,
        "username": user.username,
        "nickname": user.nickname,
        "email": user.email,
        "avatar": user.avatar,
        "intro": user.intro,
        "gender": user.gender,
        "created_at": user.created_at.strftime("%Y-%m-%d %H:%M:%S"),
    })
运行输出Python 运行结果
运行结果: # ① 带上正确的 token $ curl http://127.0.0.1:5000/api/user/profile -H "Authorization: Bearer eyJhbGciOi..." { "code": 200, "msg": "success", "data": { "id": 3, "username": "zhangsan", "nickname": "张三", "email": "zhangsan@example.com", "avatar": "", "intro": null, "gender": 0, "created_at": "2026-10-12 10:30:00" } } # ② 完全不带 Authorization 头 { "code": 401, "msg": "请先登录", "data": null }
讲透
  • @jwt_required() 一定要带括号。它是 4.x 的写法(可传 optional=True 表示「带 token 就校验、不带也放行」,适合作品详情这种「登录与否都能看,登录了顺便告诉你有没有收藏」的接口)。
  • 装饰器的顺序有讲究:@user_bp.get(...) 在最外层,@jwt_required() 在它的下面(也就是更靠近函数)——路由注册在最外,鉴权在路由内部先执行。
  • 取出的是字符串:因为签发时写了 str(user.id),这里必须 int() 转回来。忘了转,db.session.get 拿到 "3" 可能查不到,报出「用户不存在」这种莫名其妙的错。
  • 过期也是 401:token 里 exp 一旦超过当前时间,校验就失败,由前面注册的 expired_token_loader 统一返回「登录已过期,请重新登录」,前端拦截器看到 401 就清 token 跳登录页。

自定义 @login_required 装饰器

把当前用户放进 g 对象
先讲为什么「发表书评」「点赞」「收藏」「上传头像」……十几个接口都要登录,每个都写一遍 jwt_required() + get_jwt_identity() + 查库,代码重复得让人抓狂。更麻烦的是:接口文档 1.6 说要「用装饰器 @login_required 解析并校验 token,取出当前用户」——我们把它封装成一个装饰器,一次写好,处处复用,并且把查到的用户放到 g 上,视图里直接 g.user 就能用。
回望第 4 章:g 是什么
  • g 是「一次请求内共享的对象」:同一次请求的各个函数(视图、被调用的工具函数)都能读到它,请求结束自动销毁。
  • 它天生适合放「当前用户」:装饰器里查一次库放进 g.user,视图里就不用再查第二遍,也不会把 user_id 当参数层层传递。
  • 注意它只在请求上下文中有效:不能在后台线程或脚本里访问 g,否则会报 Working outside of application context。
apps/utils/auth.pypython
from functools import wraps
from flask import g, jsonify
from flask_jwt_extended import verify_jwt_in_request, get_jwt_identity
from ..models import db, User


def login_required(fn):
    """需要登录的接口装饰器:校验 JWT → 查出 User → 挂到 g.user。"""
    @wraps(fn)
    def wrapper(*args, **kwargs):
        # ① 校验请求头里的 token(不带 / 过期 / 伪造都会抛异常)
        try:
            verify_jwt_in_request()
        except Exception:
            return jsonify(code=401, msg="请先登录", data=None)

        # ② 取 user_id 并查库
        user = db.session.get(User, int(get_jwt_identity()))
        if user is None or user.status != 1:
            return jsonify(code=401, msg="账号不存在或已被禁用", data=None)

        # ③ 放进 g,供视图与后续逻辑使用(呼应第 4 章的 g 对象)
        g.user = user
        return fn(*args, **kwargs)

    return wrapper
运行输出Python 运行结果
运行结果: 装饰器定义成功,无输出(它就是一段可复用的包装逻辑)
动手做用它重写「发表书评」接口,对比一下:视图里再也不需要回答「我是谁」,直接用 g.user.id 就是了。
apps/comment/views.pypython
from flask import Blueprint, request, jsonify, g
from ..utils.auth import login_required
from ..models import db, Comment

comment_bp = Blueprint("comment", __name__, url_prefix="/api/comment")


@comment_bp.post("/add")
@login_required                       # 一行搞定鉴权 + 取当前用户
def add_comment():
    data = request.get_json(silent=True) or {}
    article_id = data.get("article_id")
    content = (data.get("content") or "").strip()
    if not article_id or not content:
        return jsonify(code=400, msg="缺少参数:article_id / content", data=None)

    comment = Comment(
        user_id=g.user.id,            # 当前用户直接来自 g,不用前端传、也传不了
        article_id=article_id,
        parent_id=data.get("parent_id"),
        content=content,
    )
    db.session.add(comment)
    db.session.commit()
    return jsonify(code=200, msg="发表成功", data={"comment_id": comment.id})
运行输出Python 运行结果
运行结果: $ curl -X POST http://127.0.0.1:5000/api/comment/add \ -H "Authorization: Bearer eyJhbGciOi..." \ -H "Content-Type: application/json" \ -d '{"article_id":1,"content":"好诗!","parent_id":null}' { "code": 200, "msg": "发表成功", "data": { "comment_id": 9 } }
避坑写装饰器时 @wraps(fn) 千万别漏。漏了之后,被包装函数的 __name__ 会变成 wrapper,导致 url_for 找不到端点、日志里所有接口都叫 wrapper、报错栈也定位不到真正的视图函数。另外 user_id 一定要从 token 里取,绝不能信任前端传的 user_id——否则伪造别人的 id 就能以他人身份发书评。

退出登录怎么做

无状态 token 的「注销」
退出登录:无状态 token 怎么「注销」 问题 . JWT 是无状态的: 服务端不存 token, 发出去就收不回来 做法一(最常用) 前端直接删掉本地 token (localStorage / Pinia) 简单,够用 服务端无需改动 做法二(更严格) 服务端维护黑名单 (Redis 存失效 token) 校验时先查黑名单 可真正「踢下线」 配套建议:token 设较短过期时间 + 刷新机制,降低泄露后的风险窗口

JWT 发出去就收不回来(无状态)。常用做法是前端删除本地 token;要求严格时服务端用 Redis 维护黑名单,校验时先查黑名单,可实现真正「踢下线」。

先讲为什么Session 时代,退出登录 = 服务器删掉那条会话记录,之后带着旧 Session ID 来也没用。但 JWT 是无状态的:服务器不保存任何 token 记录,token 一旦签发,在过期前「永远有效」。所以「退出」这件事本质是前端的事——把本地存的 token 删掉。后端接口存在的意义是记录一条日志、以及为「想要立刻作废」的场景预留黑名单机制。
动手做对照接口文档 2.6,实现一个「形式完整」的退出接口。
apps/user/views.py(续)python
@user_bp.post("/logout")
@login_required
def logout():
    # JWT 无状态:真正的「退出」由前端删除 localStorage 里的 token 完成。
    # 后端这一步的作用是记录日志;若要立刻作废 token,可走下面的黑名单方案。
    return jsonify(code=200, msg="已退出", data=None)
运行输出Python 运行结果
运行结果: $ curl -X POST http://127.0.0.1:5000/api/user/logout \ -H "Authorization: Bearer eyJhbGciOi..." { "code": 200, "msg": "已退出", "data": null } # 前端随后执行 localStorage.removeItem("token"), # 之后的请求不再带 Authorization 头,自然就是「未登录」状态
黑名单思路(进阶).pypython
from flask_jwt_extended import get_jwt, jwt_required

# 思路:把「要作废的 token 的唯一编号 jti」存进 Redis,校验时先查黑名单
# 1) 签发时不用改,token 自带 jti
# 2) 退出时把 jti 写进黑名单,过期时间设成 token 剩余寿命
# 3) 注册校验回调,命中黑名单就拒绝
blacklist = set()   # 生产环境用 Redis,这里用集合示意

@jwt.token_in_blocklist_loader
def is_revoked(header, payload):
    return payload["jti"] in blacklist

# 退出时:blacklist.add(get_jwt()["jti"])
运行输出Python 运行结果
运行结果: 命中黑名单后,该 token 再访问受保护接口会返回: { "code": 401, "msg": "该 token 已失效,请重新登录", "data": null } # 代价:每次请求多一次 Redis 查询,JWT「无状态」的优势被削弱, # 所以只在有明显安全需求时才上黑名单
讲透
  • 前端记得「连 token 带头部」一起清:只删 localStorage 里的 token 即可,之后 axios 拦截器不再附加 Authorization 头。
  • 把有效期设短能减轻注销的尴尬:本例设成 2 小时。真实项目常用「短命 access_token(15 分钟)+ 长命 refresh_token」的组合,退出时只需作废 refresh_token。
  • 黑名单的取舍:它能实现「立即失效」,但要在每个请求里查一次存储,也丧失了无状态的好处。教学阶段知道有这回事、能说清原理就够了。

Postman 鉴权测试

token 存环境变量,一键复用
动手做不用每测一个接口就手动复制一遍 token。思路是:在「登录」请求的 Tests 脚本里把返回的 token 自动存进环境变量,之后所有受保护接口的头里引用 Bearer {{token}} 即可。先在 Postman 建一个环境(如 hiwen-dev),加一个变量 base_url = http://127.0.0.1:5000/api。
登录接口 · Tests 标签页javascript
// 请求:POST {{base_url}}/user/login
// 把响应里的 token 存进当前环境,供其它接口复用

const res = pm.response.json();

pm.test("登录成功", function () {
    pm.expect(res.code).to.eql(200);
});

if (res.code === 200) {
    pm.environment.set("token", res.data.token);
    console.log("token 已保存到环境变量");
}

// 提示:先点「Send」跑一次登录,环境里才会出现 token
页面效果浏览器运行结果
运行结果: Test Results ✓ 登录成功 Console token 已保存到环境变量 # 环境 hiwen-dev 的变量列表里,token 已被自动填入一长串 eyJhbGciOi...
动手做接着在受保护接口(如 GET {{base_url}}/user/profile)里配置请求头,引用刚才存下的变量。
受保护接口 · Headers 配置text
Key            Value
Authorization  Bearer {{token}}
Content-Type   application/json

# 说明:
# 1) Bearer 与 {{token}} 之间是「一个空格」,不是冒号
# 2) {{token}} 是 Postman 的环境变量引用语法,发送时会自动替换成真实值
# 3) 若手抖漏了空格,或写成 token: xxx,都会拿到 401 请先登录
效果预览运行结果
运行结果: # 配置正确时 { "code": 200, "msg": "success", "data": { "id": 3, "username": "zhangsan", ... } } # 直接复制粘贴 token 容易复制不全或带换行,务必用环境变量引用
讲透:把整条链路串起来验收
  • 先在 Postman 里跑通 4 步:① 调 /user/register 建号 → ② 调 /user/login 自动存 token → ③ 调 /user/profile 看是否返回自己的信息 → ④ 把 Authorization 头删掉再调一次,应当拿到 401。
  • 401 的两种文案要能区分:不带 token 是「请先登录」,过期是「登录已过期,请重新登录」——正是第 4 节里 unauthorized_loader 与 expired_token_loader 的功劳。
  • Collection 变量 vs Environment 变量:token 属于当前环境,换了环境(比如切到生产)就需要重新跑一次登录——这恰恰防止了「拿测试 token 打生产」。
  • 这一步通过,本章就算过关:它正是课程索引页说的里程碑 M3 的第一段——「注册 → 登录 → 拿到 token → 带上 token 查个人信息」。
动手练习
1用 werkzeug.security 写一段脚本:对密码 "hiwen2026" 生成哈希,先打印哈希串,再分别校验 "hiwen2026" 与 "hiwen2027",观察输出。
2参照本章,实现接口文档 2.4 的「修改个人信息」PUT /api/user/profile:只允许改 nickname、intro、gender 三个字段,且只能改自己的(提示:用 @login_required 拿 g.user)。
3迁移练习:「红影赏析」电影网也要做登录,用户表叫 member(字段 id、account、pwd_hash、status)。请把本章的注册、登录、@login_required 三块代码迁移过去。想一想:换了表名和字段名,哪一行逻辑是绝对不变的?
参考答案
练习答案.pypython
# 第 1 题
from werkzeug.security import generate_password_hash, check_password_hash

h = generate_password_hash("hiwen2026")
print(h)                              # scrypt:32768:8:1$...(每次不同)
print(check_password_hash(h, "hiwen2026"))   # True
print(check_password_hash(h, "hiwen2027"))   # False


# 第 2 题:修改个人信息
@user_bp.put("/profile")
@login_required
def update_profile():
    data = request.get_json(silent=True) or {}
    user = g.user                      # 只能改自己:不接收前端传来的 user_id
    if "nickname" in data:
        user.nickname = (data["nickname"] or "").strip()
    if "intro" in data:
        user.intro = data["intro"]
    if "gender" in data:
        user.gender = data["gender"]
    db.session.commit()
    return jsonify(code=200, msg="修改成功", data=None)


# 第 3 题:红影赏析(表名 member,字段 account / pwd_hash)
# 注册
member = Member(account=account, pwd_hash=generate_password_hash(password))
# 登录
if member is None or not check_password_hash(member.pwd_hash, password):
    return jsonify(code=400, msg="账号或密码错误", data=None)
token = create_access_token(identity=str(member.id))
# 装饰器:唯一不变的是「校验 token → 取出 id → 查库 → 挂到 g.user」这条骨架
运行输出Python 运行结果
运行结果: 第 1 题输出: scrypt:32768:8:1$Kd7pQm2RxT5sWb1n$1f0c9a...(后略) True False # True / False 的组合说明:哈希不可逆,只能「重新算一遍再比对」 第 2 题输出: 修改成功后返回 { "code": 200, "msg": "修改成功", "data": null } # 试着在请求体里塞 {"id": 1},发现改不动别人——因为 user 取自 g.user 第 3 题: 换表名/字段名只改「模型属性名」, generate_password_hash、check_password_hash、create_access_token、 @login_required + g.user 这套骨架一行都不用动

三题都在验证同一件事:哈希负责「存得安全」,JWT 负责「认得出来」,装饰器负责「用得省事」。把这三件事拆开看清楚,比背代码有用得多。

本章小结

📝 本节自测

做完点「检查本节」看解析与得分;做错可重做。