FastAPI 接口安全与工程化 JWT、测试和配置管理

一条任务接口接上数据库后,最危险的问题才冒出来。只要知道任务 ID,任何人都能修改它。接口不是把数据存进去就完事,它还要回答请求来自谁,以及这个人有没有资格碰这条数据。

配套代码已经放在 fastapi-task-api,文章中的完整实现以 main 分支为准。

先注册用户,再签发令牌

密码绝不能原样入库。项目用 passlibpbkdf2_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

查询条件里同时带 idowner_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 统一处理认证,查询条件负责资源授权
  • 配置、路由、模型和后台任务各自放在清楚的位置
  • 自动化测试覆盖非法输入、鉴权和跨用户访问
相关推荐
做一个AK梦10 小时前
安全架构设计理论与实践-软考架构师
安全·安全架构
cakeism82510 小时前
Gitee CodePecker 软件供应链恶意投毒监测与治理平台:从被动响应到主动防御的供应链安全体系
安全·gitee
2601_9557594111 小时前
ClaudeAPI第三方系统接入安全审查指南
安全
凌云拓界14 小时前
NodeVerdict | .ndv 二进制格式:为 WASM 解码器设计的紧凑布局
安全·架构·开源·node.js·编辑器·软件工程·wasm
恒拓高科WorkPlus14 小时前
BeeWorks 即时通讯私有化解决方案介绍
安全
dyxal15 小时前
SSH本地端口转发完全解析:像“挖掘隧道”一样安全访问远程数据库
数据库·安全·ssh
恒拓高科WorkPlus16 小时前
什么是信创即时通讯?企业选型需要关注哪些方面?
安全
m0_5474866616 小时前
GBT33000-2025大中型工贸企业安全生产标准化管理体系全套模版word
安全
数据知道18 小时前
SSRF 漏洞实战:内网探测、云元数据窃取一条龙
网络·安全·网络安全