FastAPI 中间件的洋葱模型倒序陷阱
FastAPI 中间件的洋葱模型倒序陷阱:为什么你的 OPTIONS 预检请求会报 401?
一个看似正常的中间件配置
在开发基于 FastAPI / Starlette 的 Web 服务时,认证和跨域(CORS)几乎是每个项目的标准配置。很多人习惯像下面这样写:
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from my_auth import BearerTokenAuthMiddleware
app = FastAPI()
# 1. 允许跨域
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 2. 接口鉴权
app.add_middleware(BearerTokenAuthMiddleware)
直觉上看,这段代码完全符合直觉:先配置 CORS 允许前端跨域访问,再配置自定义的 Token 鉴权中间件保护接口。
但是在真实浏览器联调时,前端发起带有自定义请求头(比如 Authorization)的跨域请求时,浏览器控制台却会直接抛出 CORS 错误:
Access to XMLHttpRequest at 'http://api.example.com/data' from origin 'http://localhost:3000'
has been blocked by CORS policy: Response to preflight request doesn't pass access control check:
It does not have HTTP ok status.
查看网络面板,会发现浏览器自动发起的 OPTIONS 预检请求返回了 HTTP 401 Unauthorized,并且响应头里根本没有 Access-Control-Allow-Origin。
明明接口在 Postman 或 curl 里测试都很正常,为什么一到浏览器里就直接报 401?
根因分析:Starlette 的 add_middleware 是向外包裹的
FastAPI 底层基于 Starlette 构建,而 Starlette 的中间件机制采用了经典的 ASGI 包装器模型(也就是大家常说的“洋葱模型”)。
关键在于 Starlette 中 app.add_middleware(...) 的注册方式:后注册的中间件,会包裹在先注册的中间件外层,因此后注册的反而最先执行!
查看 Starlette 源码中中间件构建的逻辑(简化示意):
# Starlette 构建中间件链的顺序
def build_middleware_stack(self):
app = self.router
for cls, options in reversed(self.user_middleware):
app = cls(app, **options)
return app
当代码按以下顺序调用时:
app.add_middleware(CORSMiddleware)app.add_middleware(BearerTokenAuthMiddleware)
实际生成的执行调用链是:
客户端请求 (Request)
↓
[ BearerTokenAuthMiddleware ] ← 后注册,但在最外层,最先拦截!
↓
[ CORSMiddleware ] ← 先注册,但在内层
↓
[ 路由处理业务函数 ]
为什么 OPTIONS 预检请求会遭遇灭顶之灾?
浏览器在跨域发送复杂请求(如包含 Authorization: Bearer xxx 头或使用非简单请求方法)前,会强制发送一个不带实际业务数据和鉴权凭据的 OPTIONS 方法探测请求(Preflight Pre-check)。
预检请求有其特殊性:
- 按照 W3C 规范,浏览器在发送 OPTIONS 预检请求时,绝不会附带真实的业务 Authorization 请求头;
- 预检请求的唯一目的,是询问服务器:“你允许哪些来源、哪些 Method 和哪些 Headers”;
- 正常情况下,
CORSMiddleware在捕获到OPTIONS请求后,会直接检查并返回HTTP 200 OK,并附带Access-Control-Allow-Origin等协商头,甚至不需要将请求交给后续的鉴权和业务逻辑。
但由于中间件顺序倒置:
- 请求进入服务,最先被最外层的
BearerTokenAuthMiddleware捕获; - 鉴权中间件检查请求头
request.headers.get("Authorization"),发现为空; - 鉴权中间件直接短路,返回
JSONResponse(status_code=401, content={"error": "unauthorized"}); - 请求根本没有机会到达内层的
CORSMiddleware! - 结果返回给浏览器的 401 响应中不含有任何 CORS 头,浏览器判定预检失败,拒绝发送真实的业务请求。
最小复现实验
我们用一个干净的最小 Python 脚本验证这个行为:
import hmac
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.testclient import TestClient
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse
class BearerTokenAuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
token = request.headers.get("Authorization")
if not token or not token.startswith("Bearer "):
return JSONResponse(status_code=401, content={"error": "unauthorized"})
return await call_next(request)
# 错误顺序:CORS 先加,Auth 后加
bad_app = FastAPI()
bad_app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_headers=["*"], allow_methods=["*"])
bad_app.add_middleware(BearerTokenAuthMiddleware)
client = TestClient(bad_app)
resp = client.options("/test", headers={
"Origin": "http://example.com",
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "authorization"
})
print(f"预检请求状态码: {resp.status_code}")
print(f"CORS 响应头: {resp.headers.get('access-control-allow-origin')}")
运行输出:
预检请求状态码: 401
CORS 响应头: None
浏览器端遇到这种情况必定报错。
正确的解法
解法一:调整中间件注册顺序(最简单也是最标准的解法)
只要颠倒两者的注册顺序即可:
app = FastAPI()
# 1. 先注册认证中间件(会落在内层)
app.add_middleware(BearerTokenAuthMiddleware)
# 2. 最后注册 CORS 中间件(会包裹在最外层)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
此时洋葱结构为:
请求进入 -> [ CORSMiddleware ] -> [ BearerTokenAuthMiddleware ] -> 路由函数
当 OPTIONS 预检请求到达时,最外层的 CORSMiddleware 立即拦截并返回带 CORS 头的 200 响应;当真实业务请求到来时,通过 CORS 后再由 BearerTokenAuthMiddleware 验证 Token。
解法二:在鉴权中间件中显式放行 OPTIONS 与健康检查路径
即使注册顺序正确,防御性编程依然推荐在自定义鉴权中间件中对非凭据请求做保护:
class BearerTokenAuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
# 1. 显式放行 CORS 预检请求
if request.method == "OPTIONS":
return await call_next(request)
# 2. 显式放行容器存活探针(K8s / 负载均衡探活通常不带凭据)
if request.url.path in ("/health", "/healthz", "/metrics"):
return await call_next(request)
# 3. 正常校验 Token
token = request.headers.get("Authorization")
if not self.is_valid_token(token):
return JSONResponse(status_code=401, content={"error": "unauthorized"})
return await call_next(request)
总结
- Starlette 中间件是洋葱模型,
add_middleware向外包裹。最后添加的中间件最先执行。 CORSMiddleware几乎总是应当作为最后一个添加的中间件,确保它在最外层第一时间拦截预检并给所有响应追加 CORS 头。- 自定义鉴权中间件切忌盲目拦截所有无凭据请求,对
OPTIONS和探活路由应当具备放行意识。
- 点赞
- 收藏
- 关注作者
评论(0)