FastAPI-安全与认证
这篇讲什么
- 一条完整的认证链:密码哈希 → 登录发 JWT →
OAuth2PasswordBearer解 token →Security查 scopes,每一步都在本机跑通并贴了实测输出。 - 官方教程现在推荐
pwdlib,不再推荐passlib:实测passlib 1.7.4配bcrypt 5.0.0直接ValueError,一次哈希都做不出来;pwdlib 0.3.1的verify()返回布尔值,还有verify_and_update()支持换算法。 - JWT 用 PyJWT 就够(
python-jose实测也能跑,但已不是首选);decode()必须显式传algorithms,短密钥会告警,过期抛ExpiredSignatureError。
密码哈希怎么选
-
官方推荐
pwdlib(早期教程写的passlib已经不能用了,见下一节),装法:1
pip install "pwdlib[argon2,bcrypt]"
1
Would install pwdlib-0.3.1
-
最小可用写法,实测:
hashing.py 1
2
3
4
5
6
7
8from pwdlib import PasswordHash
password_hash = PasswordHash.recommended()
hashed = password_hash.hash("P@ssw0rd-2026")
print(hashed[:20])
print(password_hash.verify("P@ssw0rd-2026", hashed))
print(password_hash.verify("bad", hashed))1
2
3pwdlib 0.3.1
recommended() 算法前缀: $argon2id$v=19$m=655 | 校验 True | 错密码 False
hash 长度: 97verify()返回布尔值,不抛异常,写起来比argon2-cffi干净(后者错密码抛VerifyMismatchError)。recommended()当前是 argon2id(m=65536,t=3,p=4),内存硬度足够,是 OWASP 现在的首选。
-
换算法/升级参数不用停机,实测多 handler 与
verify_and_update():upgrade.py 1
2
3
4
5
6
7
8from pwdlib import PasswordHash
from pwdlib.hashers.argon2 import Argon2Hasher
from pwdlib.hashers.bcrypt import BcryptHasher
# 新哈希用第一个 handler,旧哈希仍能校验
password_hash = PasswordHash((Argon2Hasher(), BcryptHasher()))
ok, new_hash = password_hash.verify_and_update("abc", old_bcrypt_hash)1
2
3多算法: 新哈希 $argon2id$v=19 | 校 bcrypt 旧哈希 True
verify_and_update(旧哈希) -> (True, '$argon2id$v=19$m=65536,t=3,p=4$...')
verify_and_update(已是新算法) -> (True, None) # None 表示不需要重写- 这就是「老用户登录时顺手把 bcrypt 哈希升级成 argon2id」的标准做法:拿到
new_hash就写回数据库。 BcryptHasher(rounds=…, prefix=…)、Argon2Hasher(time_cost, memory_cost, parallelism, hash_len, salt_len, type)是可调参数,别在没测过吞吐前把 cost 调太高。
- 这就是「老用户登录时顺手把 bcrypt 哈希升级成 argon2id」的标准做法:拿到
-
三种库本机横向实测:
1
2
3
4
5pwdlib 0.3.1 + argon2id -> hash 97 字符,verify 返回布尔,支持 verify_and_update
argon2-cffi 25.1.0 -> hash 97 字符,$argon2id$v=19$m=65536,t=3,p=4$...
错密码抛 VerifyMismatchError;方法是 check_needs_rehash
bcrypt 5.0.0 直接用 -> hash 60 字符 $2b$12$...,hash 0.194s / check 0.197s(rounds=12)
passlib 1.7.4 -> 完全不可用,见下一节
passlib 为什么不能用
-
官方老教程里那段
CryptContext(schemes=["bcrypt"])在本机直接炸,而且报错信息跟「密码长度」有关,很容易误判成自己的问题:1
2ValueError: password cannot be longer than 72 bytes,
truncate manually if necessary (e.g. my_password[:72])- 真正的原因在 passlib 的源码里:它加载 bcrypt 后端时会做一次「wrap bug 探测」,拿一个超过 72 字节的密码去哈希(
passlib/handlers/bcrypt.py:421 detect_wrap_bug)。 bcrypt从 4.1 起不再静默截断,而是抛ValueError,于是探测本身就失败了,任何一次ctx.hash()都报这个错。- 它还会先打一条
AttributeError: module 'bcrypt' has no attribute '__about__'(passlib 用旧字段读版本号),日志里那行(trapped) error reading bcrypt version就是同一件事。 - passlib 最后一次发版是 1.7.4(2020 年),社区在 FastAPI 仓库里也提过这个issue(见参考),所以结论是:别再用 passlib,用 pwdlib。
- 真正的原因在 passlib 的源码里:它加载 bcrypt 后端时会做一次「wrap bug 探测」,拿一个超过 72 字节的密码去哈希(
-
bcrypt 的 72 字节上限本身是真实存在的(不是 passlib 的锅),实测直接调用也一样:
1
2bcrypt.hashpw(b"x" * 100, bcrypt.gensalt())
-> ValueError password cannot be longer than 72 bytes所以「超长密码/密码里带 emoji 被截断」这类问题要靠在注册侧限制长度或改用 argon2(pwdlib 的 argon2 handler 没有 72 字节限制),而不是自己截断。
JWT 的用法与坑
-
官方教程用的是
python-jose,实测它能跑(3.5.0,纯 Python,Python 3.14 上没问题):1
python-jose 3.5.0 -> 能用,长度 121 | 解回 {'sub': 'lilei', 'role': 'admin'}
但 PyJWT 更主流、维护更勤(本机 2.15.1),Flask/Django 那两篇也统一用 PyJWT,本篇按 PyJWT 写。
-
PyJWT 的四个实测行为:
jwt_demo.py 1
2
3
4
5
6import jwt
from datetime import datetime, timedelta, timezone
KEY = "0123456789abcdef0123456789abcdef" # 至少 32 字节,走环境变量
token = jwt.encode({"sub": "lilei", "role": "admin"}, KEY, algorithm="HS256")
data = jwt.decode(token, KEY, algorithms=["HS256"])1
2
3
4
5
6
7encode -> 长度 121 | header {'alg': 'HS256', 'typ': 'JWT'}
decode -> {'sub': 'lilei', 'role': 'admin'}
不传 algorithms -> DecodeError: It is required that you pass in a value for the
"algorithms" argument when calling decode()
错密钥 -> InvalidSignatureError: Signature verification failed
密钥只有 5 字节 -> 告警 InsecureKeyLengthWarning: The HMAC key is 5 bytes long,
which is below the minimum recommended length of 32 bytes for SHA256algorithms必须显式传,这是防「算法混淆攻击」的关键,不传直接报错(不是警告)。- 密钥短于 32 字节会告警:教程里
SECRET_KEY = "secret"这种写法在新版 PyJWT 上会被点名,生产要用足够长的随机串并从环境变量注入。
-
过期与常见声明实测:
jwt_claims.py 1
2
3
4now = datetime.now(timezone.utc)
token = jwt.encode({"sub": "lilei", "iat": now,
"exp": now + timedelta(minutes=30), "jti": "固定 id"},
KEY, algorithm="HS256")1
2
3
4解出的声明: {'sub': 'lilei', 'iat': '1793971187', 'exp': '1793972987', 'jti': '固定 id'}
过期后 -> ExpiredSignatureError: Signature has expired
leeway=10 -> 能过(容忍 10 秒时钟偏差)
要求 audience 但 token 里没有 -> MissingRequiredClaimError: Token is missing the "aud" claimexp是必须放的,否则 token 永久有效;iat便于审计,jti是做黑名单/单次使用的抓手。- 多实例部署时服务器时钟要同步,否则会出现「刚发的 token 立刻算过期」,
leeway是兜底不是解法。 - 用
audience=区分「登录 token / 刷新 token / 内部服务 token」,比在 payload 里塞type字段更硬。
-
别把密钥和敏感信息放进 payload:JWT 只是 base64url + 签名,任何人拿到 token 都能读出内容,实测连库都不用:
jwt_read.py 1
2
3
4
5
6
7
8
9
10
11import base64
import json
head, body, sig = token.split(".")
def b64(s):
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
print(json.loads(b64(body)))1
2
3token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJsaWxlaSJ9....
裸 base64 解 payload: {'sub': 'lilei', 'role': 'admin'}
get_unverified_header: {'alg': 'HS256', 'typ': 'JWT'}签名只保证「不被篡改」,不保证「内容保密」。要保密就得加密或改用会话。
OAuth2 密码模式全流程
-
登录接口用官方给的表单依赖,别自己拆字段:
auth.py 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23from typing import Annotated
import jwt
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pwdlib import PasswordHash
app = FastAPI()
password_hash = PasswordHash.recommended()
KEY = "从环境变量读,至少 32 字节"
USERS = {"lilei": {"password_hash": password_hash.hash("good-pass"), "role": "user"}}
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/login")
def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]):
user = USERS.get(form.username)
if not user or not password_hash.verify(form.password, user["password_hash"]):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "用户名或密码不对",
headers={"WWW-Authenticate": "Bearer"})
return {"access_token": jwt.encode({"sub": form.username}, KEY, algorithm="HS256"),
"token_type": "bearer"}1
2
3
4
5POST /login (urlencoded username=lilei&password=good-pass)
-> 200 {'access_token': 'eyJhbGciOiJIUzI1NiIs...', 'token_type': 'bearer'}
POST /login 错密码 -> 401
POST /login 用 JSON 发 -> 422
POST /login 缺 username -> 422-
必须是
application/x-www-form-urlencoded,实测用 JSON 发就是 422;文档里这个接口的requestBody.content也只有这一种类型。 -
OAuth2PasswordRequestForm实际收的字段实测:1
2
3{'username': 'u', 'password_len': 1, 'scopes': [], 'grant_type': None,
'client_id': None, 'client_secret': None}
带 scope=read write 时 -> scopes: ['read', 'write']也就是
username、password、scope(可重复)、grant_type、client_id、client_secret;要加自定义字段(比如租户号)就继承它加一个表单参数。
-
-
受保护接口靠一个「解 token」的依赖:
current_user.py 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15async def current_user(token: Annotated[str, Depends(oauth2_scheme)]):
try:
data = jwt.decode(token, KEY, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
raise HTTPException(401, "token 过期")
except jwt.InvalidTokenError:
raise HTTPException(401, "token 无效")
if data["sub"] not in USERS:
raise HTTPException(401, "用户不存在")
return data
def me(user: Annotated[dict, Depends(current_user)]):
return {"sub": user["sub"]}1
2
3带 token -> {'sub': 'lilei'}
不带 token -> 401 {'detail': 'Not authenticated'} | WWW-Authenticate: Bearer
坏 token -> 401OAuth2PasswordBearer只负责「从Authorization: Bearer xxx里取出字符串」,它不校验内容,校验在jwt.decode。- 401 一定要带
WWW-Authenticate: Bearer,Swagger UI 的 Authorize 按钮和浏览器行为都依赖它;实测HTTPBearer/OAuth2PasswordBearer自动帮你带上。 - 想让「未登录」不报错而是走匿名分支,构造时传
OAuth2PasswordBearer(tokenUrl="/login", auto_error=False),然后自己判None。
scopes 与权限
-
声明可用 scope,并在依赖里检查:
scopes_demo.py 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17from fastapi import Security, SecurityScopes
scheme2 = OAuth2PasswordBearer(tokenUrl="/login2", scopes={"read": "读", "write": "写"})
async def user_with_scopes(scopes: SecurityScopes,
token: Annotated[str, Depends(scheme2)]):
data = jwt.decode(token, KEY, algorithms=["HS256"])
have = set((data.get("scope") or "").split())
if not set(scopes.scopes) <= have:
raise HTTPException(403, "权限不足", headers={"WWW-Authenticate": "Bearer"})
return data
def need_write(u: Annotated[dict, Security(user_with_scopes, scopes=["write"])]):
return {"ok": True}1
2
3
4注入进来的 SecurityScopes 实例属性: {'scopes': ['write'], 'scope_str': 'write'}
只有 read 打 need-write -> 403 {'detail': '权限不足'}
只有 read 打 need-read -> {'ok': True}
read+write 打 need-write -> {'ok': True}- ⚠️
SecurityScopes在 0.162 上的属性是scopes和scope_str,老教程写的scopes.required_scopes实测直接AttributeError: 'SecurityScopes' object has no attribute 'required_scopes'。 SecurityScopes是按类型注入的,不要写成Depends(SecurityScopes)(那样会把它当依赖函数解析,实测拿到的是 token 字符串)。Security(依赖, scopes=[...])会把需求累加到scopes.scopes里,所以一个接口挂多个依赖时,权限是自动并集。
- ⚠️
-
文档里能看到 scopes 声明,实测:
1
2
3
4securitySchemes: {"OAuth2PasswordBearer": {"type": "oauth2",
"flows": {"password": {"scopes": {"read": "读", "write": "写"},
"tokenUrl": "/login2"}}}}
/need-write 的 security: [{"OAuth2PasswordBearer": ["write"]}]前端/第三方照着这份声明就能知道「要什么权限、去哪拿 token」。
其它认证方案
-
fastapi.security里的现成件,实测行为:1
2
3
4
5
6
7HTTPBasic 无凭据 -> 401 | WWW-Authenticate: Basic
带凭据 -> {'who': 'lilei'}(base64 解出的用户名)
坏 base64 -> 401
APIKeyHeader 带 X-API-Key -> {'k': 'k1'} | 缺失 -> 401
APIKeyQuery 带 ?api_key=k2 -> {'k': 'k2'} | 缺失 -> 401
APIKeyCookie 带 cookie session=s3 -> {'k': 's3'} | 缺失 -> 401
HTTPBearer 带 Bearer t -> {'token': 't'} | 缺失 -> 401 {'detail': 'Not authenticated'}- API Key 三种位置都可用,但查询串会进访问日志、Referer 和浏览器历史,对外接口优先 Header。
HTTPBasic每次请求都带口令,必须全站 HTTPS;适合内部服务,不适合浏览器端长期用。HTTPBearer(auto_error=False)适合「有 token 就用、没有就匿名」的场景。
-
服务端会话(cookie + 签名)用 Starlette 的
SessionMiddleware,实测:session.py 1
2
3
4
5
6
7
8
9
10from starlette.middleware.sessions import SessionMiddleware
app.add_middleware(SessionMiddleware, secret_key=KEY,
https_only=True, same_site="strict")
def sess(request: Request):
request.session["n"] = request.session.get("n", 0) + 1
return {"n": request.session["n"]}1
2
3
4第一次: {'n': 1} | Set-Cookie: session=eyJuIjogMX0=.au3T8w.VaZsbs...; path=/
第二次(自动带 cookie): {'n': 2}
https_only + same_site="strict" ->
session=...; path=/; Max-Age=1209600; httponly; samesite=strict; secure- 两个实测坑:它不在
fastapi.middleware下(from fastapi.middleware.sessions import ...会ModuleNotFoundError,要from starlette.middleware.sessions),而且依赖itsdangerous,fastapi[standard]不带它,要pip install itsdangerous(实测 2.2.0)。 - 参数名是
same_site,写成samesite会TypeError: unexpected keyword argument 'samesite'。 - 会话内容只是 base64 + 签名,不加密(
eyJuIjogMX0=一眼能解),别往里放敏感数据——这条和 Tornado-应用安全 里安全 cookie 的结论一样。
- 两个实测坑:它不在
传输与浏览器侧
- CORS 的正确配置组合,实测细节见 FastAPI-依赖注入与异常中间件的 CORS 一节,这里只强调安全相关的三条:
allow_credentials=True时allow_origins不能用"*",必须列具体源,否则浏览器直接拒。- 带凭据的接口不要开
allow_headers=["*"]图省事,按实际用到的头列。 - 预检失败是 400(实测
Disallowed CORS method),不是 401,排查时别看错方向。
- token 存哪里,是面试常问的取舍,实测事实是:
localStorage+Authorization头:XSS 一旦被打通,token 直接被偷走;不会被 CSRF 打。HttpOnlycookie(set_cookie(..., httponly=True, samesite="lax", secure=True)):JS 读不到,抗 XSS 窃取;但要靠 CSRF 防护兜着(FastAPI 没有内置 CSRF 中间件,靠SameSite+ 校验 Origin)。- 折中:access token 放内存变量 + refresh token 放
HttpOnlycookie,这也是多数现代前端的实际做法。
- HTTPS 是前提,不是可选项:
- 生产要在 nginx 侧终结 TLS 并开 HSTS,写法见 Nginx。
- 应用侧要知道「自己是代理后面」,否则
request.url会生成http://的重定向与 OAuth 回调地址,--proxy-headers与--root-path的实测写法见 FastAPI-WebSocket、测试与部署。
- 限流不在框架里,
fastapi没有内置 rate limit。常见做法是 nginxlimit_req(见 Nginx)或中间件 + Redis 计数(见 Redis入门),第三方slowapi只是把 Flask-Limiter 包了一层,依赖 Redis 时才划算。 - 密码相关字段一律不要出现在响应里:
response_model是这道防线(实测裁掉了password),别指望「前端不显示」,见 FastAPI-响应模型与OpenAPI文档。