Python 框架指南 · 第 11 章

认证与 JWT

10 章收尾时留了句话:中间件守住了「请求进门的必经之路」,下一关要在这条路上再设一道关卡——你是谁?你的令牌呢?这一章就把这道关卡立起来。 墨集 inkhub 要开放作者投稿了:作者注册账号、登录、领凭证、写文章…

约 47 分钟18,637 字26 个代码示例

10 章收尾时留了句话:中间件守住了「请求进门的必经之路」,下一关要在这条路上再设一道关卡——你是谁?你的令牌呢?这一章就把这道关卡立起来。

墨集 inkhub 要开放作者投稿了:作者注册账号、登录、领凭证、写文章、删文章,管理员还能做平台运营。围绕这套「作者账号系统」,认证的每个环节都会走到。

08 章结尾预告过一棵依赖树——get_current_user 的依赖树,本章正式兑现;09 章统一异常的 401 场景,也会在这里接上。

本章配套 demo:demo/demo_jwt.py(单文件:注册 → 登录发 token → 受保护接口 → 过期/伪造 token 的 401,内存假数据 + 内嵌 uvicorn,注释里写全 curl 三步流程)


1. 先把问题摆上桌:认证是一串连环问

写认证代码之前,先看这一串问题——它们环环相扣,跳过任何一个都会在后面还债:

  1. 作者注册时填的密码,怎么存才不怕数据库被拖走?
  2. 作者登录成功后,服务器发什么凭证才能证明「这位确实登录过」?
  3. 这张凭证的有效期在哪儿设?
  4. 签发凭证的密钥放在哪儿?
  5. 认证出错时,怎么报告——要不要新定义异常?
  6. 后续每个请求进来,服务器怎么知道是哪位作者?
  7. 怎么让某些接口必须登录才能访问?作者和管理员的权限差异怎么表达?
  8. 怎么让 /docs 一眼看出哪些接口需要认证,还能现场试?
  9. 凭证的有效期到了,作者就得重新输密码吗?

这九问就是本章的目录:§2–3 答密码,§4 答凭证选型,§5–7 答结构与密钥,§8 答当前用户,§9 答接口上锁,§10 答文档标识,§11 答续期。最后一节速查表按问回收,一题对一答。


2. 密码怎么存:明文是埋在库里的火药桶

为什么明文不行

把「先存明文,反正数据库有密码」当方案的人,等于在仓库里堆火药桶,赌它永远不炸。而现实是,拖库、误配置、备份外流,年年都有。明文密码一旦泄露,灾难是三倍的:

  • 撞库:用户在墨集用的密码,八成和小信箱、支付账号是同一个。攻击者拿一份明文库,能横扫整个人互联网上的账号。
  • 信任崩塌:用户无法分辨「泄露的只是墨集还是我的整个人生」。
  • 不可挽回:明文一旦出门,没有任何技术手段收回来。

为什么「加密存」也不行

有人退一步:那我用 AES 把密码加密了存,总安全了吧?不行——

  1. 加密是可逆的,密钥也得放在服务器上。拖库的人往往连密钥一起拖走。
  2. 服务器根本不需要知道明文。登录校验只需要回答一个问题:「用户这次输入的,和注册时输入的,是不是同一个?」——验证一致性,不需要还原内容。
  3. 「忘记密码」功能只需要重置,永远不该「找回」。哪个网站能把原密码发给你,它就必然存着明文或可逆密文,扭头注销。

正解:存哈希——碎纸机,不是保险箱

比喻:服务器门口放一台碎纸机。作者注册时,密码明文进机器,绞成一堆纸屑(哈希值)存进库;纸屑的纹路无法拼回原文,但纹路由输入唯一决定——登录时把这次输入的密码也绞一遍,对比两堆纸屑的纹路,对上了就放行。整条链路上,明文只出现两次:用户键盘上、那一次哈希运算里。

哈希算法几个性质,正好为密码而生:任意长度进、固定长度出;不可逆;输入变一个字符,输出面目全非。

但裸哈希还不够,因为碎纸机太标准了——全世界所有网站用同一款 SHA-256,就给了攻击者机会:

彩虹表:攻击者预先把「常见密码 → 哈希值」算了几亿条,做成一本字典。拖库之后不破解、只查表:拿库里的哈希值对字典,123456 这种密码秒回明文。快速哈希反而帮了攻击者——你的服务器一次算 0.001 毫秒,攻击者暴力猜一次也只要 0.001 毫秒,一天能猜上百亿次。

两道补丁,组合起来就是现代密码存储:

手段 干什么 效果
加盐 哈希前掺入一段随机数据 同一个密码每次哈希结果都不同,预制字典(彩虹表)作废,攻击者只能针对这一条记录从头猜
慢哈希 故意把算法设计得很慢、可调成本 每猜一次都要花几十上百毫秒,暴力破解从「一天百亿」掉到「一年万」

bcrypt = 加盐 + 慢哈希的合体:随机盐由它自动生成、直接嵌在输出里(不用单独存一列盐),成本因子(rounds)可调。这就是「为什么用 bcrypt 而不是 SHA-256」的完整答案——不是更玄的算法,是更对题的算法。

$2b$12$N9qo8uLOickgx2ZMRZoMye...(60 个字符)
 ↑   ↑
 |   └─ 成本因子:12 → 每次哈希约几十毫秒(每 +1 翻倍)
 └─ 版本标识 + 22 字符随机盐 + 31 字符哈希,盐嵌在输出里

常见坑

  • 想给哈希值「加密一层」、或者用可逆方式存密码——方向反了,回到火药桶。
  • 拿 MD5/SHA-1 存密码——2020 年代这属于事故现场。
  • bcrypt 有 72 字节的密码长度上限(算法决定的),超长部分会被静默忽略;新近版本会直接报错提醒你。想支持长密码,主流做法是先整体做一次 SHA-256 再喂给 bcrypt。
  • 同一个密码注册两次,库里两行哈希长得不一样——这不是 bug,恰恰说明盐在工作。

3. bcrypt 与 passlib:两套写法都过一遍

Python 圈写 bcrypt 哈希有两套姿势,都得认识:bcrypt 裸用(轻量、现状最稳)和 passlib 全家桶(经典、老项目遍地)。

姿势一:bcrypt 裸用(本章 demo 采用)

作用:三函数搞定存与验——hashpw 绞、gensalt 产盐、checkpw 对纹路。

签名:

bcrypt.hashpw(password: bytes, salt: bytes) -> bytes
bcrypt.gensalt(rounds: int = 12) -> bytes
bcrypt.checkpw(password: bytes, hashed_password: bytes) -> bool

最小示例(项目里就封这两个函数,全局复用):

import bcrypt


def hash_password(password: str) -> str:
    """注册/改密码时用:明文绞成带盐哈希,返回可入库的字符串"""
    return bcrypt.hashpw(
        password.encode("utf-8"), bcrypt.gensalt(rounds=12)
    ).decode("ascii")


def verify_password(plain: str, hashed: str) -> bool:
    """登录时用:把输入当场再绞一遍,和库里的对比纹路"""
    return bcrypt.checkpw(
        plain.encode("utf-8"), hashed.encode("ascii")
    )


h1 = hash_password("ink-forest-2026")
h2 = hash_password("ink-forest-2026")
print(h1 == h2)                        # False:盐不同,纹路不同
print(verify_password("ink-forest-2026", h1))   # True
print(verify_password("wrong", h1))    # False

常见坑

  • 这三个函数吃的是 bytes,库里存的是 str——进出各做一次 encode/decode,写错类型就 TypeError。
  • checkpw 每次调用都要真跑一遍慢哈希(这就是它的本职),别放进高频循环里。
  • 别自己生成盐再拼接——用 gensalt(),让盐自动嵌进输出,省一列存储还少一个出错点。

姿势二:passlib 的 CryptContext(老项目常见)

作用:把多种哈希方案装进一个上下文,自动处理「老算法升级」——注册用新算法,登录时发现旧哈希还能验,验完顺手升级。

签名:

from passlib.context import CryptContext

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
pwd_context.hash(password: str) -> str
pwd_context.verify(password: str, hashed: str) -> bool

最小示例:

hashed = pwd_context.hash("ink-forest-2026")     # '$2b$12$...'
pwd_context.verify("ink-forest-2026", hashed)    # True

常见坑(当下最响的一个)

  • passlib 1.7.4 已停更多年,和 bcrypt 4.1+ 不兼容。组合到一起会翻出一团迷惑的错:ValueError: password cannot be longer than 72 bytes——报错说的是长度,实际与长度无关,是版本探测坏了;日志里还常伴一句 (trapped) error reading bcrypt version。两条出路: 1. 把 bcrypt 钉在 bcrypt==4.0.1(老项目最省事); 2. 抛弃 passlib,直接用 bcrypt 库(新项目推荐,也是本章 demo 的选择)。
  • 网上老教程几乎全用 passlib——读得懂,新代码不必跟着写。

存与验的落点

注册和登录两个接口里,这两函数的站位:

# 注册:存的是哈希,明文即用即弃
user = Author(username=data.username, password_hash=hash_password(data.password))

# 登录:先查人,再对纹路;两步失败给同一句话
user = await get_user_by_name(data.username)
if user is None or not verify_password(data.password, user.password_hash):
    raise UnauthorizedError("用户名或密码错误")

常见坑

  • 登录失败时别说「用户不存在」「密码错误」这种分头报错的话——攻击者可以拿报错差异批量探测哪些用户名注册过(账号枚举)。统一一句「用户名或密码错误」。
  • verify 前先查库、查无此人就短路返回——但性能洁癖者的讲究做法是对一个固定假哈希也跑一次 verify,让「用户不存在」和「密码不对」耗时一致,防时序侧信道。教学项目知道即可。

4. 凭证选型:寄存柜手环 vs 防伪门票

密码验过了,接下来发凭证。凭什么是「可信的」?两条主流路线,先打一个游乐园的比方:

Session(寄存柜手环):你进园,服务员在柜子(服务端存储)里放一张你的信息卡,给你一个手环编号(cookie 里的 session id)。之后玩每个项目,刷手环 → 服务员跑去柜子查卡。身份的真相在柜子里,手环只是个号码。

Token / JWT(防伪门票):门票上直接印着你是谁(VIP、几点前进场),盖上骑缝防伪章。玩任何项目,验票员当场验章读票——不用回服务台查任何柜子。身份的真相在票面上,章的可信度由密钥保证。

维度 Session(有状态) JWT(无状态)
身份存哪 服务端(内存 / Redis) 票面自带(payload)
每次请求要查库/缓存吗 要 验签即可,可以不查
横向扩容 所有实例共享那个存储(黏人) 任何实例持密钥即可验(自食其力)
注销 / 封禁 删柜子里的卡,立即生效 难——票发出去就收不回(见 §11)
移动端 / 小程序 / 跨域 cookie 那套在跨端场景别扭 一个字符串放哪儿都行
网关 / 微服务 网关还得回源查 session 网关本地验签直接放行

选型不是玄学,看架构形态:

  • 前后端分离 + 多端(网页、App、小程序)+ 可能多服务——墨集正是这个形态——JWT 是主流答案;
  • 单体服务端渲染网站、强注销需求(后台管理系统)——Session 依然好用,别为了时髦硬上 JWT。

定位:JWT 只是「token 凭证的一种具体纸面格式」——把「你是谁、何时签发、何时作废」印成一串有标准的字符。下一节拆开看这张票。


5. 拆开看 JWT:header.payload.signature

一张真实的 JWT 长这样(三段,两个点隔开):

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJsaW5tbyIsInJvbGUiOiJhdXRob3IiLCJleHAiOjE3NTk5OTk5OTl9.dBw3x6Vq9mQm8e5TqS9aQUnJ8uGj9D9Eh4JmQGn_Vw4

两个点把票裁成三联:header.payload.signature。

三联各是什么

import base64, json

token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJsaW5tbyIsInJvbGUiOiJhdXRob3IiLCJleHAiOjE3NTk5OTk5OTl9.dBw3x..."

def b64url_decode(seg: str) -> str:
    # JWT 用 base64url:+→-、/→_、去掉补位 =,所以手工解要补回来
    return base64.urlsafe_b64decode(seg + "=" * (-len(seg) % 4)).decode()

print(json.loads(b64url_decode(token.split(".")[0])))
# {"alg": "HS256", "typ": "JWT"}        ← 第一联 header

print(json.loads(b64url_decode(token.split(".")[1])))
# {"sub": "linmo", "role": "author", "exp": 1759999999}
#                                       ← 第二联 payload(claims)
  • header:说明书——「这张票用 HS256 算法验章」。定死的小字典。
  • payload:票面信息,官方叫 claims(声明)。标准字段常用的有:
  • sub(subject):这张票属于谁——用户 id 或用户名,标准要求是字符串;
  • iat(issued at):何时签发;
  • exp(expired):几点作废——凭证的有效期就设在这里(问题清单第 3 问的答案落点);
  • jti(JWT id):票号,全局唯一,将来拉黑名单全靠它;
  • 自定义字段:role 这类业务信息也可以印上,但记住下一条铁律。
  • signature:骑缝章。生成方式一句话:
signature = HMAC-SHA256(base64(header) + "." + base64(payload), 密钥)

HMAC 是「带密钥的哈希」(消息认证码):没有密钥,算不出这个章;内容动一个字符,章就对不上。

三条铁律(背下来,面试和事故现场都用得上)

  1. JWT 不加密,只防篡改。 base64url 是编码不是加密——payload 就是明信片,任何人拿到 token 都能读出 sub/role/手机号。所以:密码、敏感资料永远不进 payload。要加密另有 JWE 标准,本课不碰。 比喻:payload 是明信片,signature 是骑缝章——邮路上人人可读明信片,但没人能改一个字而不被验出。
  2. HS256 是对称密钥:签章和验章同一把钥匙。谁能签,谁也能验;密钥泄露 = 全线失守。多服务场景下更常用 RS256(非对称):签发方持私钥,各验证方只要公钥——发 token 的认证中心和只验 token 的业务服务,权力天然分离。
  3. 防篡改 ≠ 防转借。 票是真的,不代表持票人是本人——token 被偷,验票机照样放行。所以:全程 HTTPS、有效期设短(§11 的 refresh token 就是为这两难设计的)。

常见坑

  • 「token 里的数据是加密的吧,放个手机号没事」——不,随手 base64 解码就看到了。
  • 把 payload 塞成权限明细大全(几十个字段)——token 每次请求都随身携带,越胖越浪费;角色放一个 role 够用。
  • 手工拿 base64.b64decode 去解 JWT 段报错——它是 urlsafe 变体且无补位,见上面 b64url_decode 的补齐写法。

6. PyJWT:签发与验票

安装:pip install pyjwt(注意不是 jwt、不是 python-jwt——重名包是老坑)。

jwt.encode:签一张票

作用:把 claims 字典 + 密钥 → 一段三段式 token 字符串。

签名:

jwt.encode(payload: dict, key: str | bytes, algorithm: str = "HS256") -> str

最小示例:

import jwt
from datetime import datetime, timedelta, timezone

now = datetime.now(timezone.utc)

token = jwt.encode(
    {
        "sub": "linmo",                 # 谁(必须是字符串,见坑 2)
        "role": "author",               # 自定义声明
        "iat": now,                     # PyJWT 接受 datetime,自动转时间戳
        "exp": now + timedelta(minutes=30),   # ← 过期时间就设在这
    },
    key="一串至少 32 字节的密钥",
    algorithm="HS256",
)
print(token)   # eyJhbGciOiJIUzI1NiIs...

常见坑

  • exp 填带时区的 UTC datetime(datetime.now(timezone.utc))。填 naive 时间,PyJWT 按本地时区解释,跨国部署直接差出几个小时的过期时间。
  • 密钥别用几个字符糊弄——HMAC-SHA256 推荐至少 32 字节;新近版本的 PyJWT(2.13+)会对短密钥发 InsecureKeyLengthWarning,看到别无视。

jwt.decode:验一张票

作用:密钥 + 算法验章、自动检查 exp(过期抛异常)→ 返回 claims 字典。

签名:

jwt.decode(jwt: str, key: str | bytes, algorithms: list[str]) -> dict

最小示例:

payload = jwt.decode(token, key="一串至少 32 字节的密钥", algorithms=["HS256"])
# {'sub': 'linmo', 'role': 'author', 'iat': ..., 'exp': ...}

# 想强制「必须有 exp 才算合法票」:
jwt.decode(token, key, algorithms=["HS256"], options={"require": ["exp", "sub"]})

异常族(谁抛什么,一张表收走):

异常 什么时候抛 给用户的话术
jwt.ExpiredSignatureError exp 已过 token 已过期,请重新登录
jwt.InvalidSignatureError 票被改过 / 密钥不对 token 无效
jwt.InvalidSubjectError sub 不是字符串(PyJWT 2.10+ 起强校验) token 无效
jwt.DecodeError 格式根本不是 JWT / 缺 algorithms 参数 token 无效
jwt.InvalidTokenError 上面全家的父类 token 无效

常见坑(按疼痛程度排序)

  1. algorithms 必须显式传。不传,decode 直接抛 DecodeError 拒绝工作。这不是框架刻薄——当年「算法混淆攻击」(把票上的 alg 换成 none 或把 RS256 票混进 HS256 验签)坑过一票知名库,显式声明「我只接受 HS256」就是疫苗。永远不要 algorithms=[token_header里的alg](等于没设防),写死白名单。
  2. sub 必须是字符串。传整数 id 进去,PyJWT 2.10+ 直接抛 InvalidSubjectError。存 user.id 的话,签发前 str(user.id),用的时候再 int() 回来。
  3. 先捕子类再捕父类。except jwt.ExpiredSignatureError 写在 except jwt.InvalidTokenError 前面,才能给「过期」和「无效」两种话术;顺序反了,「已过期」永远落到「无效」的兜里。顺带一提,ExpiredSignatureError 是 InvalidTokenError 的子类。

封装成项目小工具(demo 同款思路)

import uuid
from datetime import datetime, timedelta, timezone
import jwt

def create_access_token(sub: str, secret: str, *, minutes: int = 30) -> str:
    now = datetime.now(timezone.utc)
    payload = {
        "sub": sub,                                   # 谁
        "jti": str(uuid.uuid4()),                     # 票号(拉黑名单用)
        "iat": now,                                   # 签发时间
        "exp": now + timedelta(minutes=minutes),      # 过期时间
    }
    return jwt.encode(payload, secret, algorithm="HS256")

def decode_access_token(token: str, secret: str) -> dict:
    return jwt.decode(token, secret, algorithms=["HS256"])

7. 密钥放哪:SECRET_KEY 的家规

签发和验票都靠那把密钥。问题清单第 4 问——它配置在哪?先立家规再给写法。

为什么绝不能写进代码:git 会记住每一次提交。今天「临时」写死、下周删除,历史里仍躺着那把钥匙——任何人 clone 仓库,git log -p 就能考古出来。比喻:把印章母版刻在门牌上,还指望路过的人不拓印。更要命的是:代码会被复制、被截图、被打包进镜像,密钥跟着到处旅行。

家规三条:

  1. 密钥只从环境变量来,代码里只有读取语句;
  2. .env 文件必须进 .gitignore,仓库里只留 .env.example(占位符版);
  3. 开发、测试、生产各一把不同的钥匙——测试库泄露不伤生产。

生成一把像样的密钥(64 个十六进制字符 = 32 字节,够 HS256 用):

openssl rand -hex 32
# 3f9a2c...(64 个字符,别自己手打「123456」当密钥)

读取(呼应 03 章 settings 的玩法,这里给最朴素的版本):

import os

SECRET_KEY = os.environ["INKHUB_JWT_SECRET"]   # 缺了直接 KeyError,启动期就炸,好排查
ACCESS_TOKEN_MINUTES = 30                      # 过期时长也归配置管,不散落代码

工程化一点就用 pydantic-settings 收进 Settings 类(03 章原班用法),和数据库 URL 并排躺好。

常见坑

  • 用 "secret"、"123456"、项目名当密钥——攻击者字典里它们排前三。
  • .env 忘进 .gitignore,密钥随首次 commit 进历史;事后删除文件没用,要轮换密钥并清历史。
  • 密钥泄露的处置只有一条路:换钥匙。代价是全体在线用户的 token 立即失效、集体掉线——这已经是最好的结局了。
  • demo 里常见的「环境变量读不到就退回一个写死的默认值」写法,只许出现在教学代码里,注释里一定标明「生产禁止」。

8. get_current_user:从一根请求头到一位作者

08 章结尾预告的依赖树,本章兑现。先看树长什么样,再逐层搭。

get_current_user          ← 路由要的「当前作者」,本章主角
    ├── oauth2_scheme     ← 拦下 Authorization 头,抽出 token(fastapi.security)
    └── settings          ← 提供密钥、过期时长(§7 的配置)

前置知识:Authorization 头的格式

JWT 按惯例放在 Authorization 请求头里,格式是 Bearer <token>(RFC 6750):

Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

Bearer 意为「持票人」——这串票给谁谁就能用(再次呼应:防转借靠 HTTPS + 短有效期)。

完整实现(demo 同款骨架)

from typing import Annotated
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")

async def get_current_user(
    token: Annotated[str, Depends(oauth2_scheme)],   # 子依赖①:抽出 token
) -> User:
    try:
        payload = decode_access_token(token, SECRET_KEY)   # 子依赖②:密钥来自配置
    except jwt.ExpiredSignatureError as e:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="token 已过期,请重新登录",
            headers={"WWW-Authenticate": "Bearer"},
        ) from e
    except jwt.InvalidTokenError as e:                 # 改过票 / 错钥匙 / 格式烂 → 全在这
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="token 无效",
            headers={"WWW-Authenticate": "Bearer"},
        ) from e

    user = await get_user(payload["sub"])             # 拿着 sub 去库里找人
    if user is None:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="token 无效",
            headers={"WWW-Authenticate": "Bearer"},
        )
    return user

CurrentUser = Annotated[User, Depends(get_current_user)]   # 08 章的别名玩法

路由侧从此极简:

@app.get("/users/me")
async def read_me(me: CurrentUser) -> dict:
    return {"id": me.id, "username": me.username, "role": me.role}

逐层解说,对着问题清单对答案:

  • 「怎么知晓当前登录的用户」——不靠 magic:每个带锁接口挂上 get_current_user,依赖先于路由执行(08 章 §3 的执行流程),路由函数伸手就拿到现成的 User。
  • 为什么验完签还要查库——token 只证明「曾经签给过 sub 这个人」,不证明此人现在还在:可能已注销、被封禁、被删除。查一次库,把「过去的票」兑换成「现在的身份」。顺带一提:payload["sub"] 出来是字符串,转 int 再查 id。
  • 「要不要为认证定义新异常」——原问题清单里的一问,两种姿势:
  • 轻量派(本章 demo):直接抛 HTTPException(401, detail=...),注意带上 headers={"WWW-Authenticate": "Bearer"}——这是 HTTP 规范对 401 的配套要求,缺失会让不少客户端库不发凭证重试逻辑。
  • 工程派(接 09 章体系):定义 class UnauthorizedError(BusinessException),三行——http_status = 401、code = "401001"、default_message = "请先登录"。统一信封、统一注册,前台拿到的是 {"code": "401001", "message": ...} 而不是裸 detail。两种都对,看项目有没有 09 章那套地基。

常见坑

  • 在每个路由函数里手抄一遍「解头 → 验票 → 查人」——08 章反对的「写死依赖」在这里原样复刻。一次封装,全站复用,这是依赖注入的主场。
  • except 顺序写反(父类在前),过期和无效的区分话术永远出不来——§6 坑 3 的现场版。
  • 忘了 from e——日志里异常因果链断掉,排障时看不到「401 ← 哪个底层异常」。
  • 401 和 403 用混:401 是「你是谁我没认出来」(没票、假票、过期票);403 是「认出来了,但你不配」(座位不对)。下一节展开。

9. 接口上锁与角色分级

凭证有了、当前用户有了,问题清单第 7 问:怎么让接口必须登录才能访问?作者和管理员的权限差异怎么表达?

上锁的三种粒度

# ① 单个接口上锁:挂依赖就完事
@app.post("/articles")
async def create_article(data: ArticleIn, me: CurrentUser) -> dict: ...

# ② 一组路由整体上锁:router 级 dependencies(08 章 §3 的老朋友)
router = APIRouter(
    prefix="/articles",
    dependencies=[Depends(get_current_user)],   # 组内所有接口自动验票
)

# ③ 整个 app 上锁
app = FastAPI(dependencies=[Depends(get_current_user)])

多角色:依赖工厂再套一层

判断权限的逻辑也别散进路由函数体——再包一层依赖。依赖工厂:一个返回「依赖函数」的函数,08 章「依赖的依赖」的实战变体:

from collections.abc import Callable

def require_roles(*roles: str) -> Callable[..., User]:
    """按需生产守门依赖:require_roles("admin") 造出一个『只放管理员』的依赖"""
    async def checker(me: CurrentUser) -> User:      # 先过 get_current_user,再查角色
        if me.role not in roles:
            raise HTTPException(status_code=403, detail="权限不足")
        return me
    return checker

AdminOnly = Annotated[User, Depends(require_roles("admin"))]

@app.delete("/articles/{article_id}")
async def delete_article(article_id: int, me: AdminOnly) -> dict: ...

get_current_user → checker → 路由,三层的依赖树。401 在第一层拦「没票的」,403 在第二层拦「票对但座位不对」——两道闸各司其职:

401 Unauthorized 403 Forbidden
含义 认证失败:没登录 / 票无效或过期 认证成功、鉴权失败:认出你了,但不行
谁能解决 客户端:去登录、换新 token 服务端:改权限或用户换账号,重试没用
对应本节 get_current_user 抛 require_roles 抛

更细的业务判断(比如「删文章:本人或管理员」)属于属主校验,得进路由或服务层(要同时看 me 和 article.author,纯声明式依赖表达不了)——demo 里给了完整写法。

常见坑

  • 权限 if 写在路由函数体里满地都是——文档上看不出接口需要什么角色,测试也只能整条业务跑。能进依赖的判断都进依赖。
  • 依赖里只验角色、忘了先过认证——require_roles 必须依赖 get_current_user(先拿到 me 才谈得上 me.role),树断了 403 会早于 401 出现,客户端懵。
  • 想改权限时发现要改几百个函数签名——这就是「上锁进依赖/别散业务」在还债。顺带一提原问题清单里那句「修改访问权限后对应的测试脚本需要修改」:测试的解药在 12 章——dependency_overrides 把 get_current_user 换成「永远返回测试作者」的替身(08 章 §11 的手法),权限怎么改,测试都稳。

10. /docs 里的 Authorize 按钮:security scheme

问题清单第 8 问:怎么让 API 文档直观反映出哪些接口需要认证?

是什么

OpenAPI 规范里,每个接口可以标注「我需要哪种安全方案」(security scheme)。FastAPI 里这个标注不用手写——你在依赖里用了 OAuth2PasswordBearer 或 HTTPBearer,挂在路由上,框架自动完成三件事:

  1. 在 OpenAPI 文档里注册一个安全方案;
  2. 给受保护接口打上锁图标 🔒;
  3. 在 /docs 顶部渲染出 Authorize 按钮——点开、填凭证、之后的「Try it out」自动带上 Authorization 头。

OAuth2PasswordBearer:让文档替你走完整登录流

作用:声明「本 API 用 OAuth2 密码模式换 token」;同时它本身是个依赖——从请求头里解出 Bearer <token> 的 token 字符串,没有就 401。

签名:

from fastapi.security import OAuth2PasswordBearer

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
#                                    ↑ 告诉文档:想换 token,把表单发到这个地址

最小示例(配合 §8 的 get_current_user):

@app.get("/users/me")
async def read_me(me: CurrentUser) -> dict: ...   # /docs 里此接口旁出现锁图标

Authorize 按钮的完整体验:打开 /docs → 点 Authorize → 填 username / password → 文档把这两个值以表单形式 POST 到 tokenUrl → 拿到响应里的 access_token 存进浏览器 → 之后每个「Try it out」自动带 Authorization: Bearer ...。整个过程零 curl、零 Postman,产品经理也能自己调试受保护接口——这就是它相对 HTTPBearer 的杀手锏。

配套的登录接口要用表单(demo 的写法):

from fastapi.security import OAuth2PasswordRequestForm

@app.post("/auth/login")
async def login(
    form: Annotated[OAuth2PasswordRequestForm, Depends()],
) -> TokenOut:
    user = authenticate(form.username, form.password)   # 验密(§3)
    token = create_access_token(str(user.id), SECRET_KEY)
    return {"access_token": token, "token_type": "bearer"}

OAuth2PasswordRequestForm 替你声明了 username / password 两个表单字段,和 Authorize 按钮发出来的格式严丝合缝。

常见坑(这一节最大的坑)

  • tokenUrl 只是说明书,不是代理。框架不会替你实现登录接口,更不会校验它——它只是写进文档的一句话。你的登录接口不存在或吃 JSON,按钮按下去就是 404 / 422。
  • 按钮发的是 application/x-www-form-urlencoded 表单,不是 JSON。登录接口若声明成 data: LoginIn(Pydantic body),Authorize 按钮必然 422——它发的表单你的 JSON 接口读不懂。两条路:登录接口用表单(如上,需安装 python-multipart,FastAPI 处理表单的硬前提,忘装会在请求时报错);或者放弃按钮自动流,用下面的 HTTPBearer 手动贴 token。
  • 等价但更轻的选择:HTTPBearer——同样出锁图标和 Authorize 按钮,但按钮里填的是 token 本身(适合「先用 curl 登录拿 token、再贴进文档试」的纯 JSON 工作流),不需要表单、不需要 python-multipart。它作为依赖返回 HTTPAuthorizationCredentials 对象(.credentials 即 token)。小坑:老版本 Starlette 的 HTTPBearer 在头缺失时回 403 而非 401(历史行为,升级或锁依赖版本时留意,实测新版本已统一为 401);想自己控制话术,就 HTTPBearer(auto_error=False) 让它安静地返回 None,由你的 get_current_user 统一抛 401。
  • 响应模型里 token 字段必须叫 access_token、类型标注为 str,token_type 用 "bearer"——Authorize 按钮按这几个名字取值,写错一个,按钮「成功」了却存不进 token。

选型一句话

想给文档使用者全自动的登录体验(联调、演示、非技术同事)→ OAuth2PasswordBearer + 表单登录;纯程序化消费的 API(前后端都自己写代码)→ HTTPBearer 也绰绰有余。


11. refresh token:一票两联

问题清单最后一问:凭证设了 30 分钟有效期,作者写到一半 token 过期,就要重新输密码?

核心矛盾:有效期短,泄露的票很快就废(安全);但用户老是被踢下线(体验)。有效期长,体验顺滑;可票一旦被偷,小偷也爽很久(风险)。

解法:把一张票拆成一票两联。

  • 入场联(access token):短命(30 分钟~2 小时),随每个请求出示,验票机认它;
  • 补票联(refresh token):长命(7~30 天),只在补票口出示(专门的 /auth/refresh 接口),用来换一张新的入场联,自己永远进不了场。
登录 ──► 发两联:access(30min) + refresh(7d)
   │
   ├─ 日常请求带 access ──► 过期 ──► 401
   │                                    │
   │                                    ▼
   │              拿 refresh 调 /auth/refresh ──► 新 access(可能连 refresh 也换新)
   │
   └─ refresh 也过期 ──► 只好重新登录

价值在哪:access 被偷 → 半小时后自然作废;refresh 被偷 → 它不能直接访问接口,只能去补票口换票,而补票口是可以加防线的(限频、绑定设备、轮换时发现旧 refresh 二次使用即全族作废)。安全性没涨多少倍,但「短命凭证满天飞」的风险窗口收窄了一个数量级。

工程上的几个讲究(概念课,demo 不实现,记住图和原则即可):

  • refresh 同样是 JWT(多了个 type: "refresh" 声明,或干脆用不同密钥签发,杜绝拿入场联去补票);
  • Web 端 refresh 常存 httpOnly cookie(JS 读不到,防 XSS 顺手偷走);App 端存安全存储区;
  • 轮换(rotation):每次 refresh 换新时旧 refresh 作废,发现旧的又被用过 = 可能被偷,强制下线。

顺带把「注销难题」收了:JWT 是无状态的,票发出去就收不回——这是 §4 表格里「注销难」的根。工程解法是黑名单:注销时把 token 的 jti(§5 的票号)记进 Redis 并给个不短于 exp 的 TTL,get_current_user 查库时顺手查黑名单。代价是「无状态」掺了点状态——所以轻量项目常用替代方案:不搞注销,只把 exp 设短硬扛。两难如实摆着,选哪个看业务对「登出」的较真程度。


12. 本章速查表(按开头清单回收)

问题清单 答案 所在节
密码怎么存? 哈希 + 盐 + 慢算法(bcrypt),绝不存明文/可逆密文 §2–3
发什么凭证? 无状态选 JWT token,有状态选 session;多端分离 → JWT §4
凭证怎么构造? header.payload.signature;payload 明文只防篡改 §5
过期时间在哪设? payload 的 exp 声明,jwt.encode 时写入 §5–6
密钥在哪配置? 环境变量 → .env(进 .gitignore),openssl rand -hex 32 生成 §7
出错怎么报告? 轻量 HTTPException(401, headers={"WWW-Authenticate": "Bearer"});工程接 09 章异常体系 §8
怎么知道当前用户? get_current_user 依赖:解 Bearer 头 → decode → 查库 → 401 §8
接口怎么上锁? 路由/路由组/app 挂 Depends(get_current_user);角色用依赖工厂 require_roles;401 vs 403 §9
文档怎么标识认证? OAuth2PasswordBearer/HTTPBearer → 锁图标 + Authorize 按钮 §10
过期就得重登吗? refresh token 一票两联:短 access 换长 refresh §11

配套 demo:demo/demo_jwt.py——单文件跑完整条链路:注册(bcrypt 哈希入库)→ 表单登录(发 JWT)→ /users/me、发布/删除文章(依赖上锁 + 角色判断)→ 实验接口(领一枚已过期或错误密钥签发的 token 去撞 401)。内嵌 uvicorn,文件头注释里有 curl 三步流程。

cd demo
python demo_jwt.py        # 或 uvicorn demo_jwt:app --port 8311

作业

  1. 数据库被拖走、库里存的是 bcrypt 哈希——攻击者拿彩虹表为什么基本白干?bcrypt 输出里那 22 字符的盐起了什么作用?
  2. JWT 的 signature 是怎么算出来的?为什么说「JWT 不加密,只防篡改」——这句话对「payload 里能放什么」立了什么规矩?
  3. jwt.decode 的 algorithms 参数为什么必须显式传 ["HS256"] 而不能省、更不能跟着 token 的 header 走?
  4. get_current_user 里验签成功后为什么还要查一次数据库?跳过查库直接信任 payload 里的 sub,会漏掉哪些场景?
  5. 401 和 403 各在 get_current_user / require_roles 的哪一层抛出?客户端收到 401 和 403 之后,各自正确的下一步是什么?

认证这道关卡立好了:每个受保护的接口,进门先亮票。下一章要给「关卡本身」写测试——怎么在不真的注册登录的情况下测 get_current_user 后面的业务逻辑?08 章 §11 埋的 dependency_overrides 替身手法,正式登场。

下一章:12 · pytest 测试