Files

135 lines
6.2 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
id: python.fastapi.deps-auth-errors
title: 依赖注入、鉴权、中间件和统一错误处理
type: lesson
domain: python
stack: [fastapi]
tags: [Depends, 鉴权, JWT, 中间件, 异常处理器, 多租户]
level: intermediate
prerequisites: [python.fastapi.routing-validation]
est_hours: 3
related: [python.fastapi.streaming-testing, backend.gin.middleware-auth, python.fastapi.interview]
week: [4, 5]
order: 2
---
# 依赖注入、鉴权、中间件和统一错误处理
## 解释说明
**依赖注入 `Depends`**:把“获取当前用户”“获取数据库会话”“获取模型客户端”写成函数,路由参数里声明 `Depends(函数)`,FastAPI 自动调用并把结果传进来。
- 依赖可以嵌套(获取当前用户 → 依赖解析 token → 依赖读取配置)。
- 带 `yield` 的依赖可以做资源清理(请求结束后关闭数据库会话)。
- 同一个请求里同一个依赖默认只执行一次(有缓存)。
- **测试时用 `app.dependency_overrides[原依赖] = 假实现`** 替换,这是 FastAPI 最好用的测试手段。
**鉴权**:常见两种——给服务调用方的 API Key(请求头 `X-API-Key`),给用户的 JWT(`Authorization: Bearer ...`)。写成依赖:校验失败抛 `HTTPException(401)`;权限不足抛 403。多租户系统里,依赖返回“当前租户”,后续查询都带上租户条件。
**中间件**:每个请求都要经过的逻辑——请求 ID、耗时日志、CORS。`@app.middleware("http")` 或添加 Starlette 中间件类。业务相关的校验(鉴权、权限)用依赖更合适,因为能精确控制作用于哪些路由。
**统一错误处理**:`@app.exception_handler(异常类型)`。
- `HTTPException` → 统一格式 `{"code", "message", "request_id"}`;
- `RequestValidationError` → 把 422 转成统一格式,列出字段和原因;
- 兜底 `Exception` → 记录完整日志,返回 500 和通用提示,**不返回内部细节**。
和 Gin 对照:`Depends` 类似在中间件里 `c.Set("user", ...)` 然后在 handler 里取,但它有类型、按路由声明、可以在测试中替换。
## 案例
AI 服务给多个业务系统调用:每个系统一个 API Key,对应一个租户;每个请求日志都带 `request_id` 和租户;参数错误返回统一格式让调用方能程序化处理;内部异常(数据库连接串、堆栈)只进日志。
## 代码示例
```python
from collections.abc import Iterator
from typing import Annotated
from fastapi import Depends, FastAPI, Header, HTTPException, Request
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
class Session: # 模拟数据库会话
def __init__(self) -> None:
self.closed = False
def query_docs(self, tenant: str) -> list[str]:
return [f"{tenant} 的文档 {i}" for i in range(2)]
opened: list[Session] = []
def get_session() -> Iterator[Session]: # 带 yield 的依赖:请求结束后清理
s = Session()
opened.append(s)
try:
yield s
finally:
s.closed = True
KEYS = {"key-a": "tenant-a", "key-b": "tenant-b"}
def current_tenant(x_api_key: Annotated[str | None, Header()] = None) -> str:
if not x_api_key or x_api_key not in KEYS:
raise HTTPException(status_code=401, detail="API Key 无效")
return KEYS[x_api_key]
def require_admin(tenant: Annotated[str, Depends(current_tenant)]) -> str: # 依赖嵌套
if tenant != "tenant-a":
raise HTTPException(status_code=403, detail="没有管理权限")
return tenant
app = FastAPI()
@app.exception_handler(HTTPException)
async def http_error(request: Request, exc: HTTPException):
return JSONResponse({"code": exc.status_code, "message": exc.detail}, status_code=exc.status_code)
@app.get("/documents")
def list_docs(tenant: Annotated[str, Depends(current_tenant)],
db: Annotated[Session, Depends(get_session)]) -> list[str]:
return db.query_docs(tenant) # 查询一律带租户条件
@app.delete("/cache")
def clear_cache(tenant: Annotated[str, Depends(require_admin)]) -> dict:
return {"cleared_by": tenant}
c = TestClient(app)
print(c.get("/documents").json()) # {'code': 401, 'message': 'API Key 无效'}
print(c.get("/documents", headers={"X-API-Key": "key-b"}).json()) # ['tenant-b 的文档 0', 'tenant-b 的文档 1']
print(opened[-1].closed) # True:请求结束后会话已关闭
print(c.delete("/cache", headers={"X-API-Key": "key-b"}).status_code) # 403
# 测试里替换依赖:不需要真实的 API Key 和数据库
app.dependency_overrides[current_tenant] = lambda: "tenant-test"
print(c.get("/documents").json()) # ['tenant-test 的文档 0', ...]
app.dependency_overrides.clear()
```
请求 ID 中间件、422 统一格式、500 兜底的完整写法见 [examples/chat-api/app.py](examples/chat-api/app.py)。
## 易错点
- **鉴权写在中间件里对所有路径生效**:健康检查、文档页面也被拦住;用依赖按路由声明更精确(或在路由器级别 `dependencies=[Depends(...)]`)。
- **多租户只在入口校验,查询时忘了带租户条件**:越权读到别的租户数据。把租户作为数据访问层的必传参数。
- **兜底异常处理器把 `str(exc)` 返回给调用方**:泄露数据库地址、内部路径。
- **测试后不清理 `dependency_overrides`**:影响其他测试。
- **API Key 明文存储、用 `==` 比较**:存哈希,用 `secrets.compare_digest` 比较以防时序攻击。
- **自定义路由用了 `/docs`、`/redoc`、`/openapi.json`**:和 FastAPI 自带的文档页面冲突(本课示例最初就踩了这个坑,`GET /docs` 返回的是 Swagger 页面)。换路径,或者 `FastAPI(docs_url=None)` 关闭/改名自带文档——生产环境通常也应该关闭或加鉴权。
- **在依赖里做很重的操作**(每个请求都查一次配置中心):考虑缓存或在应用启动时加载。
## 面试常问
见 [FastAPI 面试题](interview.md):Depends 的原理和用途、中间件和依赖的区别、怎么统一错误格式、怎么做多租户隔离。