一条任务接口接上数据库后,最危险的问题才冒出来。只要知道任务 ID,任何人都能修改它。接口不是把数据存进去就完事,它还要回答请求来自谁,以及这个人有没有资格碰这条数据。
配套代码已经放在 fastapi-task-api,文章中的完整实现以 main 分支为准。
先注册用户,再签发令牌
密码绝不能原样入库。项目用 passlib 的 pbkdf2_sha256 生成不可逆摘要,登录时只验证摘要是否匹配。这里不用刷新令牌,也不做第三方登录,先把最小的 Bearer JWT 链路跑通。
python
from datetime import UTC, datetime, timedelta
import jwt
from passlib.context import CryptContext
password_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
def create_access_token(user_id: str) -> str:
payload = {
"sub": user_id, # subject 保存用户主键
"exp": datetime.now(UTC) + timedelta(minutes=60),
}
return jwt.encode(payload, settings.secret_key, algorithm="HS256")
注册接口保存邮箱和密码摘要,登录接口验证后返回 access_token。生产环境的 SECRET_KEY 必须放在环境变量里,不能沿用仓库中的示例值,更不要写进镜像。
当前用户是一个依赖
每个需要保护的路由都手写解码 JWT,很快就会有十几个略微不同的版本。FastAPI 的依赖注入正好适合收拢这段逻辑。
python
from fastapi import Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
bearer_scheme = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
session: AsyncSession = Depends(get_db_session),
) -> User:
try:
payload = jwt.decode(credentials.credentials, settings.secret_key, algorithms=["HS256"])
user_id = uuid.UUID(payload["sub"])
except (jwt.InvalidTokenError, KeyError, ValueError) as error:
raise HTTPException(status_code=401, detail="Invalid access token") from error
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=401, detail="User no longer exists")
return user
路由拿到的是 User 对象,不是字符串令牌。认证只证明你是谁,授权才决定你能做什么。 这个区别在任务详情里很重要。
python
async def owned_task(session: AsyncSession, task_id: uuid.UUID, owner_id: uuid.UUID) -> Task:
task = await session.scalar(select(Task).where(Task.id == task_id, Task.owner_id == owner_id))
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
return task
查询条件里同时带 id 和 owner_id。返回 404 而不是告诉客户端资源存在但无权访问,可以少泄漏一层业务信息。回到 CRUD,读取、更新、删除都复用这一段代码。
把项目从一个文件拆开
文件变多不是目的,职责清楚才是。当前脚手架的结构如下。
text
app/
api/routes/ 路由、状态码、请求和响应
api/deps.py 会话、当前用户和 Redis 依赖
core/ Settings 与安全函数
db/ 引擎和 Base
models/ SQLAlchemy 表模型
schemas/ Pydantic 输入输出模型
workers/ Celery 应用和后台任务
tests/ 接口行为测试
不是说每个项目都该分成很多层。只有三五个接口的小工具,单文件完全合理。这个任务 API 已经同时有数据库、鉴权、缓存和后台任务,把它们揉在 main.py 里,修改一个地方就容易误伤别处。
测试把规则留下来
手工在 /docs 点一遍很爽,却不能保证下次重构仍然安全。HTTPX 的 ASGI Transport 能直接把请求送进 FastAPI 应用,测试不必真的监听端口。
python
async def test_task_owner_isolation(client: AsyncClient) -> None:
first = await register_and_login(client, "first@example.com")
second = await register_and_login(client, "second@example.com")
created = await client.post("/api/v1/tasks", json={"title": "Private"}, headers=first)
response = await client.get(f"/api/v1/tasks/{created.json()['id']}", headers=second)
assert response.status_code == 404 # 不能读取其他用户的任务
测试数据库使用隔离的异步 SQLite,Redis 依赖替换成内存 FakeRedis。这样测试关注接口行为,不要求开发机先启动四个容器。真正接 PostgreSQL 和 Redis 的链路,留给 Docker Compose 做最后验证。
#mermaid-svg-eaLHHZ7fDxZWRVI4{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-eaLHHZ7fDxZWRVI4 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-eaLHHZ7fDxZWRVI4 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-eaLHHZ7fDxZWRVI4 .error-icon{fill:#552222;}#mermaid-svg-eaLHHZ7fDxZWRVI4 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-eaLHHZ7fDxZWRVI4 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-eaLHHZ7fDxZWRVI4 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-eaLHHZ7fDxZWRVI4 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-eaLHHZ7fDxZWRVI4 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-eaLHHZ7fDxZWRVI4 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-eaLHHZ7fDxZWRVI4 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-eaLHHZ7fDxZWRVI4 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-eaLHHZ7fDxZWRVI4 .marker.cross{stroke:#333333;}#mermaid-svg-eaLHHZ7fDxZWRVI4 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-eaLHHZ7fDxZWRVI4 p{margin:0;}#mermaid-svg-eaLHHZ7fDxZWRVI4 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-eaLHHZ7fDxZWRVI4 .cluster-label text{fill:#333;}#mermaid-svg-eaLHHZ7fDxZWRVI4 .cluster-label span{color:#333;}#mermaid-svg-eaLHHZ7fDxZWRVI4 .cluster-label span p{background-color:transparent;}#mermaid-svg-eaLHHZ7fDxZWRVI4 .label text,#mermaid-svg-eaLHHZ7fDxZWRVI4 span{fill:#333;color:#333;}#mermaid-svg-eaLHHZ7fDxZWRVI4 .node rect,#mermaid-svg-eaLHHZ7fDxZWRVI4 .node circle,#mermaid-svg-eaLHHZ7fDxZWRVI4 .node ellipse,#mermaid-svg-eaLHHZ7fDxZWRVI4 .node polygon,#mermaid-svg-eaLHHZ7fDxZWRVI4 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-eaLHHZ7fDxZWRVI4 .rough-node .label text,#mermaid-svg-eaLHHZ7fDxZWRVI4 .node .label text,#mermaid-svg-eaLHHZ7fDxZWRVI4 .image-shape .label,#mermaid-svg-eaLHHZ7fDxZWRVI4 .icon-shape .label{text-anchor:middle;}#mermaid-svg-eaLHHZ7fDxZWRVI4 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-eaLHHZ7fDxZWRVI4 .rough-node .label,#mermaid-svg-eaLHHZ7fDxZWRVI4 .node .label,#mermaid-svg-eaLHHZ7fDxZWRVI4 .image-shape .label,#mermaid-svg-eaLHHZ7fDxZWRVI4 .icon-shape .label{text-align:center;}#mermaid-svg-eaLHHZ7fDxZWRVI4 .node.clickable{cursor:pointer;}#mermaid-svg-eaLHHZ7fDxZWRVI4 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-eaLHHZ7fDxZWRVI4 .arrowheadPath{fill:#333333;}#mermaid-svg-eaLHHZ7fDxZWRVI4 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-eaLHHZ7fDxZWRVI4 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-eaLHHZ7fDxZWRVI4 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-eaLHHZ7fDxZWRVI4 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-eaLHHZ7fDxZWRVI4 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-eaLHHZ7fDxZWRVI4 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-eaLHHZ7fDxZWRVI4 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-eaLHHZ7fDxZWRVI4 .cluster text{fill:#333;}#mermaid-svg-eaLHHZ7fDxZWRVI4 .cluster span{color:#333;}#mermaid-svg-eaLHHZ7fDxZWRVI4 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-eaLHHZ7fDxZWRVI4 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-eaLHHZ7fDxZWRVI4 rect.text{fill:none;stroke-width:0;}#mermaid-svg-eaLHHZ7fDxZWRVI4 .icon-shape,#mermaid-svg-eaLHHZ7fDxZWRVI4 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-eaLHHZ7fDxZWRVI4 .icon-shape p,#mermaid-svg-eaLHHZ7fDxZWRVI4 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-eaLHHZ7fDxZWRVI4 .icon-shape .label rect,#mermaid-svg-eaLHHZ7fDxZWRVI4 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-eaLHHZ7fDxZWRVI4 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-eaLHHZ7fDxZWRVI4 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-eaLHHZ7fDxZWRVI4 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 请求
Bearer JWT
当前用户依赖
owner_id 条件
任务数据
配置管理别靠记忆
.env.example 只放字段名和本地示例。.env 进入 .gitignore,线上用平台的密钥管理或环境变量注入。数据库地址、Redis 地址、令牌过期时间都由 Settings 读取,这比在十个文件里搜索 localhost 靠谱得多。
说实话,JWT 不是认证的终点。令牌撤销、刷新令牌、角色权限都是真实需求,但一口气写完只会让主线失焦。当前项目先把资源归属和测试覆盖做扎实。
本篇收口
- 密码保存摘要,登录后签发短时 Bearer JWT
get_current_user统一处理认证,查询条件负责资源授权- 配置、路由、模型和后台任务各自放在清楚的位置
- 自动化测试覆盖非法输入、鉴权和跨用户访问