认证与 JWT
10 章收尾时留了句话:中间件守住了「请求进门的必经之路」,下一关要在这条路上再设一道关卡——你是谁?你的令牌呢?这一章就把这道关卡立起来。 墨集 inkhub 要开放作者投稿了:作者注册账号、登录、领凭证、写文章…
10 章收尾时留了句话:中间件守住了「请求进门的必经之路」,下一关要在这条路上再设一道关卡——你是谁?你的令牌呢?这一章就把这道关卡立起来。
墨集 inkhub 要开放作者投稿了:作者注册账号、登录、领凭证、写文章、删文章,管理员还能做平台运营。围绕这套「作者账号系统」,认证的每个环节都会走到。
08 章结尾预告过一棵依赖树——
get_current_user的依赖树,本章正式兑现;09 章统一异常的 401 场景,也会在这里接上。
本章配套 demo:demo/demo_jwt.py(单文件:注册 → 登录发 token → 受保护接口 → 过期/伪造 token 的 401,内存假数据 + 内嵌 uvicorn,注释里写全 curl 三步流程)
1. 先把问题摆上桌:认证是一串连环问
写认证代码之前,先看这一串问题——它们环环相扣,跳过任何一个都会在后面还债:
- 作者注册时填的密码,怎么存才不怕数据库被拖走?
- 作者登录成功后,服务器发什么凭证才能证明「这位确实登录过」?
- 这张凭证的有效期在哪儿设?
- 签发凭证的密钥放在哪儿?
- 认证出错时,怎么报告——要不要新定义异常?
- 后续每个请求进来,服务器怎么知道是哪位作者?
- 怎么让某些接口必须登录才能访问?作者和管理员的权限差异怎么表达?
- 怎么让
/docs一眼看出哪些接口需要认证,还能现场试? - 凭证的有效期到了,作者就得重新输密码吗?
这九问就是本章的目录:§2–3 答密码,§4 答凭证选型,§5–7 答结构与密钥,§8 答当前用户,§9 答接口上锁,§10 答文档标识,§11 答续期。最后一节速查表按问回收,一题对一答。
2. 密码怎么存:明文是埋在库里的火药桶
为什么明文不行
把「先存明文,反正数据库有密码」当方案的人,等于在仓库里堆火药桶,赌它永远不炸。而现实是,拖库、误配置、备份外流,年年都有。明文密码一旦泄露,灾难是三倍的:
- 撞库:用户在墨集用的密码,八成和小信箱、支付账号是同一个。攻击者拿一份明文库,能横扫整个人互联网上的账号。
- 信任崩塌:用户无法分辨「泄露的只是墨集还是我的整个人生」。
- 不可挽回:明文一旦出门,没有任何技术手段收回来。
为什么「加密存」也不行
有人退一步:那我用 AES 把密码加密了存,总安全了吧?不行——
- 加密是可逆的,密钥也得放在服务器上。拖库的人往往连密钥一起拖走。
- 服务器根本不需要知道明文。登录校验只需要回答一个问题:「用户这次输入的,和注册时输入的,是不是同一个?」——验证一致性,不需要还原内容。
- 「忘记密码」功能只需要重置,永远不该「找回」。哪个网站能把原密码发给你,它就必然存着明文或可逆密文,扭头注销。
正解:存哈希——碎纸机,不是保险箱
比喻:服务器门口放一台碎纸机。作者注册时,密码明文进机器,绞成一堆纸屑(哈希值)存进库;纸屑的纹路无法拼回原文,但纹路由输入唯一决定——登录时把这次输入的密码也绞一遍,对比两堆纸屑的纹路,对上了就放行。整条链路上,明文只出现两次:用户键盘上、那一次哈希运算里。
哈希算法几个性质,正好为密码而生:任意长度进、固定长度出;不可逆;输入变一个字符,输出面目全非。
但裸哈希还不够,因为碎纸机太标准了——全世界所有网站用同一款 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 是「带密钥的哈希」(消息认证码):没有密钥,算不出这个章;内容动一个字符,章就对不上。
三条铁律(背下来,面试和事故现场都用得上)
- JWT 不加密,只防篡改。 base64url 是编码不是加密——payload 就是明信片,任何人拿到 token 都能读出 sub/role/手机号。所以:密码、敏感资料永远不进 payload。要加密另有 JWE 标准,本课不碰。 比喻:payload 是明信片,signature 是骑缝章——邮路上人人可读明信片,但没人能改一个字而不被验出。
- HS256 是对称密钥:签章和验章同一把钥匙。谁能签,谁也能验;密钥泄露 = 全线失守。多服务场景下更常用 RS256(非对称):签发方持私钥,各验证方只要公钥——发 token 的认证中心和只验 token 的业务服务,权力天然分离。
- 防篡改 ≠ 防转借。 票是真的,不代表持票人是本人——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 无效 |
常见坑(按疼痛程度排序)
algorithms必须显式传。不传,decode 直接抛DecodeError拒绝工作。这不是框架刻薄——当年「算法混淆攻击」(把票上的 alg 换成none或把 RS256 票混进 HS256 验签)坑过一票知名库,显式声明「我只接受 HS256」就是疫苗。永远不要algorithms=[token_header里的alg](等于没设防),写死白名单。sub必须是字符串。传整数 id 进去,PyJWT 2.10+ 直接抛InvalidSubjectError。存 user.id 的话,签发前str(user.id),用的时候再int()回来。- 先捕子类再捕父类。
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 就能考古出来。比喻:把印章母版刻在门牌上,还指望路过的人不拓印。更要命的是:代码会被复制、被截图、被打包进镜像,密钥跟着到处旅行。
家规三条:
- 密钥只从环境变量来,代码里只有读取语句;
.env文件必须进.gitignore,仓库里只留.env.example(占位符版);- 开发、测试、生产各一把不同的钥匙——测试库泄露不伤生产。
生成一把像样的密钥(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,挂在路由上,框架自动完成三件事:
- 在 OpenAPI 文档里注册一个安全方案;
- 给受保护接口打上锁图标 🔒;
- 在
/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
作业
- 数据库被拖走、库里存的是 bcrypt 哈希——攻击者拿彩虹表为什么基本白干?bcrypt 输出里那 22 字符的盐起了什么作用?
- JWT 的 signature 是怎么算出来的?为什么说「JWT 不加密,只防篡改」——这句话对「payload 里能放什么」立了什么规矩?
jwt.decode的algorithms参数为什么必须显式传["HS256"]而不能省、更不能跟着 token 的 header 走?get_current_user里验签成功后为什么还要查一次数据库?跳过查库直接信任 payload 里的 sub,会漏掉哪些场景?- 401 和 403 各在
get_current_user/require_roles的哪一层抛出?客户端收到 401 和 403 之后,各自正确的下一步是什么?
认证这道关卡立好了:每个受保护的接口,进门先亮票。下一章要给「关卡本身」写测试——怎么在不真的注册登录的情况下测 get_current_user 后面的业务逻辑?08 章 §11 埋的 dependency_overrides 替身手法,正式登场。
下一章:12 · pytest 测试