《AI Agent 工程实战》4.1 认证:JWT 与 API Key 中间件

本节给 ResearchCopilot 装上第一道门:用 FastAPI 依赖注入实现认证,同时支持 Bearer JWT 与 API Key 两种凭证。我们用 PyJWT 2.15.1 真跑签发与校验,演示合法 token 通过、过期 token 401、篡改签名 401 三种情况,并把认证失败统一到 3.3 的错误契约。

4.1 认证:JWT 与 API Key 中间件

第 3 章我们把 ResearchCopilot 的 REST 与 SSE 接口都开出来了,但它们现在对全世界敞开:任何人拿到 URL,就能读别人的会话、跑别人的任务。这一节补上「你是谁」这一课——只有先确认了身份,后面「你能做什么」(4.2 授权)和「危险动作要不要二次确认」(4.3 审批)才有意义。认证(authentication)与授权(authorization)是两件事:前者回答「你是谁」,后者回答「你能碰哪些资源」。混在一起写,是很多项目越改越乱的起点。

本节把 ResearchCopilot 推进到「有门」:新增 api/auth.py,用 FastAPI 的依赖注入实现认证,同一个依赖同时接受 Bearer JWT 与 X-API-Key,并让所有认证失败都走 3.3 定下的统一错误契约。

4.1.1 认证为什么必须做成「依赖」,而不是散落各处

最容易写错的版本,是在每个路由函数开头手写一遍「读 header → 解析 token → 校验」。它有三宗罪:重复(每个接口抄一遍)、不一致(有人漏了过期检查,有人忘了校验签名)、不可组合(测试时要绕过认证,得挨个接口打补丁)。

FastAPI 的依赖注入(dependency injection)正好治这三宗罪。把认证写成一个依赖函数,路由用 Depends(authenticate) 声明它需要「已认证的调用方」:

@app.get("/v1/runs/{run_id}")
def get_run(run_id: str, principal: dict = Depends(authenticate)):
    ...

依赖函数要么返回一个 principal(含 workspace_id / user_id),要么直接抛 401。路由函数根本看不到凭证,它只拿到「已经验证过的人」。这样一来,认证逻辑只有一份,测试时用 app.dependency_overrides 一行就能替换掉它。这就是把「横切关注点」从业务代码里抽出来的标准手法。

顺带澄清一个常见疑问:为什么用依赖,而不是 ASGI 中间件? 两者都能拦截请求,但职责不同:

ASGI 中间件FastAPI 依赖
粒度全局,每个请求都过按路由声明,可细到单个接口
拿到的信息只有 request可注入其它依赖、可返回类型化对象
适用加 header、限流、访问日志认证、授权、取当前用户

认证放依赖里,是因为不是每个接口都要认证——健康检查、登录接口本身就不需要。中间件会强迫所有请求走一遍,还得在中间件里判断「这个路径要不要豁免」,反而更绕。

4.1.2 两种凭证:JWT 与 API Key 的分工

ResearchCopilot 面向两类调用方,需要的凭证也不同:

调用方场景凭证特点
人类用户(浏览器 / 前端)登录后调 APIBearer JWT短期、可带 ws/sub 声明、到期自动失效
机器 / 脚本 / CI服务间调用X-API-Key长期、可吊销、无过期时间概念

JWT(JSON Web Token) 是一段自包含的字符串:header.payload.signature 三段点分。payload 里放 sub(用户 id)、ws(工作区 id)、exp(过期时间),签名用服务端密钥签出。服务端校验时不需要查库——只要签名对、没过期,就认这个 token。这也是它的代价:签发后无法单方面撤销,只能靠短有效期 + 刷新机制把窗口压小。

API Key 则相反:它本身没有信息,只是一个随机串,服务端拿它去表里查。好处是随时可吊销、可绑定租户;坏处是每次都要查一次存储(生产里用 Redis 缓存,见第 6 章)。

两者用同一个依赖处理,是因为它们最终都要回答同一个问题:这次调用属于哪个 workspace_id、哪个 user_id。把结果统一成 principal 字典,下游代码就不必关心调用方到底用哪种凭证来的。

4.1.3 认证依赖:两种凭证,一个出口

下面是 api/auth.py 的核心。注意 authenticate 先看 Authorization,没有再看 X-API-Key,都没有才拒绝——这个顺序让浏览器用户和脚本用户共用同一个依赖。

# api/auth.py(节选)
import time
import jwt
from fastapi import Header
from starlette.exceptions import HTTPException

# 仅供本地演示:真实环境从密钥管理注入(见 2.2),绝不能硬编码
SECRET = "dev-secret-not-for-production-32bytes"
ALGO = "HS256"

# API Key 表:演示用内存字典;生产在 DB / Redis,且只存哈希
API_KEYS = {"key_acme_demo": {"workspace_id": "ws_acme", "user_id": "u_api"}}


def make_token(sub="u_1", ws="ws_acme", ttl=3600, secret=SECRET) -> str:
    now = int(time.time())
    return jwt.encode(
        {"sub": sub, "ws": ws, "iat": now, "exp": now + ttl},
        secret, algorithm=ALGO,
    )


def authenticate(
    authorization: str | None = Header(default=None),
    x_api_key: str | None = Header(default=None, alias="X-API-Key"),
) -> dict:
    if authorization:
        scheme, _, token = authorization.partition(" ")
        if scheme.lower() != "bearer" or not token:
            raise HTTPException(401, "malformed_authorization")
        try:
            claims = jwt.decode(token, SECRET, algorithms=[ALGO])
        except jwt.ExpiredSignatureError:
            raise HTTPException(401, "token_expired")
        except jwt.InvalidTokenError:
            raise HTTPException(401, "invalid_token")
        return {"workspace_id": claims["ws"], "user_id": claims["sub"], "auth": "jwt"}

    if x_api_key:
        rec = API_KEYS.get(x_api_key)
        if rec is None:
            raise HTTPException(401, "invalid_api_key")
        return {**rec, "auth": "api_key"}

    raise HTTPException(401, "missing_credentials")

几个刻意的选择:

  • algorithms=[ALGO] 必须显式传。jwt.decode 如果允许调用方从 token header 里自选算法,就中了经典的 alg=none / 算法混淆攻击——攻击者把 alg 改成 none 或换成对称算法,服务端就可能用错密钥去验。锁死算法列表是硬性纪律。
  • 异常分类。ExpiredSignatureError 与其它 InvalidTokenError 分开抛,是因为它们的语义不同:过期是「正常的生命周期结束」,应当引导用户刷新;签名无效是「可疑」,要记审计日志(第 15 章)。
  • x_api_key 用 alias="X-API-Key",因为 Python 参数名不能带连字符。
  • API_KEYS 只存明文 key 在演示里可行,生产里要存哈希——明文存储意味着一旦库被读走,所有 key 立刻可用。

4.1.4 真跑:合法 / 过期 / 篡改三种情况

把上面的依赖挂到一个最小 app 上,用 TestClient 跑六种输入。注意 app.exception_handler 把 HTTPException 重包成 3.3 的统一错误体,并注入 request_id:

# demo_41.py(节选)
from fastapi import Depends, FastAPI, Header, Request
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from starlette.exceptions import HTTPException as StarletteHTTPException

app = FastAPI()

@app.middleware("http")
async def attach_request_id(request: Request, call_next):
    request.state.request_id = "req_" + request.headers.get("X-Request-Id", "local")
    return await call_next(request)

@app.exception_handler(StarletteHTTPException)
async def error_envelope(request: Request, exc: StarletteHTTPException):
    return JSONResponse(status_code=exc.status_code, content={
        "code": exc.detail,
        "message": MESSAGES.get(exc.detail, exc.detail),
        "details": {},
        "request_id": getattr(request.state, "request_id", "req_?"),
    })

@app.get("/v1/runs/{run_id}")
def get_run(run_id: str, principal: dict = Depends(authenticate)):
    return {"run_id": run_id, "workspace_id": principal["workspace_id"],
            "user_id": principal["user_id"], "auth": principal["auth"]}

client = TestClient(app)
tampered = make_token()[:-3] + "AAA"   # 改掉签名最后三个字符

用 venv 里的 Python 3.14.6 + PyJWT 2.15.1 真跑,输出如下:

合法 JWT                 200  {'run_id': 'run_1', 'workspace_id': 'ws_acme', 'user_id': 'u_1', 'auth': 'jwt'}
过期 JWT                 401  token_expired  token 已过期
篡改签名 JWT              401  invalid_token  token 签名无效或结构损坏
合法 API Key             200  {'run_id': 'run_1', 'workspace_id': 'ws_acme', 'user_id': 'u_api', 'auth': 'api_key'}
无效 API Key             401  invalid_api_key  API Key 无效
无凭证                   401  missing_credentials  缺少凭证:请带 Bearer token 或 X-API-Key

三种失败各有一个稳定的 code,这正是下一小节要展开的契约。注意「篡改签名」用的是合法 token 改掉末尾三个字符——服务端重新计算 HMAC 后发现对不上,直接判 invalid_token,根本不会去看 payload 里的内容。这也是为什么 JWT 敢把 ws 放在客户端可见的 payload 里:内容可读,但改不动。

演示密钥 dev-secret-not-for-production-32bytes 仅用于本地,长度刻意 ≥32 字节——PyJWT 2.15.1 对短于 32 字节的 HMAC 密钥会发 InsecureKeyLengthWarning(RFC 7518 §3.2 要求),生产密钥应由密钥管理生成并轮换。

4.1.5 认证失败的错误契约(与 3.3 统一)

3.3 定下的错误体是扁平四字段 code / message / details / request_id。认证失败不另起炉灶,只是 code 取认证专属的一组值:

codeHTTP触发条件客户端应对
missing_credentials401既无 Authorization 也无 X-API-Key提示登录
malformed_authorization401scheme 不是 Bearer 或 token 为空修客户端拼装
token_expired401exp 已过静默刷新后重试
invalid_token401签名不符 / 结构损坏强制重新登录
invalid_api_key401key 不在表里或被吊销换 key

落到响应体上,就是 3.3 那个错误体里装进认证专属的 code:

{
  "code": "token_expired",
  "message": "token 已过期",
  "details": {},
  "request_id": "req_8f3c1a"
}

request_id 不是装饰。客户端报「我 401 了」,你靠它能在日志里精确定位这一次请求;没有它,你只能在一堆 401 里猜。所以认证失败也必须带 request_id,不能因为是「安全相关的错误」就少给上下文。

有一条安全纪律必须写进契约:认证失败一律 401,且响应体不透露「这个 key 存在但过期」还是「这个 key 根本不存在」的差别(invalid_token 里刻意不区分「签名错」与「用户不存在」)。响应体越具体,攻击者越省事。唯一的例外是 token_expired——它是给合法用户的刷新信号,暴露它利大于弊。

还有一个容易漏的点:认证失败不能带 WWW-Authenticate 之外的可枚举信息,并且要限速。否则 401 接口本身就成了撞库入口。限速属于第 6 章的配额体系,这里先把契约钉死。

4.1.6 凭证的生命周期:TTL、刷新与吊销

认证一旦上线,「签出去就不管」是最危险的姿态。两种凭证的生命周期管理完全不同:

凭证有效期刷新方式吊销方式
JWT短(15–60 分钟)用 refresh token 换新 access token难——只能靠短 TTL 自然过期
API Key长(数周到永久)不刷新,到期换新 key易——从表里删掉/标记吊销即刻生效

JWT 的 TTL 选择是个取舍。 太短,用户频繁刷新、体验差;太长,一旦泄露、在过期前都无法撤销。工程上的标准做法是双 token:access_token 短命(如 15 分钟)且无状态校验,refresh_token 长命(如 30 天)且存库可吊销。access 过期就用 refresh 换新的;发现异常就把 refresh 吊销,攻击者最多用 15 分钟。

API Key 反过来:它靠「可吊销」而不是「短命」。 因为 key 常常硬编码在 CI 配置里,换 key 要走发布流程,不能频繁。所以 key 的安全靠三点:只存哈希(脱库也拿不到明文)、可即时吊销(从表里标记即可)、绑定租户与用途(一个 key 只属于一个 workspace,泄露影响面受限)。

还有一个必须提前想的场景:密钥轮换。服务端签名密钥(SECRET)一旦怀疑泄露,要能换掉。但直接换会让所有在飞的 token 立刻失效——用户被迫重新登录。所以生产 JWT 会引入 kid(key id):token header 里带上 kid,服务端按 kid 选对应密钥验签。签发用新密钥,验签同时认新旧两把,等老 token 自然过期后再撤掉老密钥,全程用户无感。演示里省略了 kid,但它是「无感轮换」的必要条件。

关注点演示做法生产做法
access token1 小时、无刷新15 分钟 + refresh 换新
密钥轮换单密钥kid 多密钥,先加后撤
API Key 存储明文内存哈希 + 可吊销 + 绑租户

4.1.7 真实环境下会怎样

本节用内存 API_KEYS 字典和固定 SECRET,是为了让示例自足可跑。真实环境有三个必改项:

演示做法生产做法原因
SECRET 硬编码从密钥管理加载、定期轮换(2.2)泄露即全线失守
API_KEYS 明文内存存哈希 + Redis 缓存 + 可吊销(6.1)支持吊销、抗脱库
只有 401401 + 审计日志 + 限速防撞库、可追溯

真实 JWT 还会引入 kid(密钥 id) 支持多密钥轮换:签发用新密钥,验签按 kid 选对应公钥,老 token 到期前仍可验。这是「无感轮换」的标准做法,演示里省略了。

验收清单:

  • 认证是一个依赖函数,业务路由只拿到 principal,看不到凭证。
  • jwt.decode 显式锁定算法列表,杜绝 alg=none。
  • 过期与篡改分成两个 code,且都不泄露多余信息。
  • 所有认证失败走 3.3 的统一错误体(code / message / details / request_id),带 request_id。

认证解决了「你是谁」,但「你」能碰哪些数据还没定。下一节把 workspace_id 变成所有资源查询的强制条件,做行级租户隔离。想回看认证失败要遵循的统一错误体,翻 3.3 幂等、分页与统一错误契约 。

阅读导航:上一节:3.3 幂等、分页与统一错误契约 · 下一节:4.2 授权与租户隔离:workspace 行级隔离 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距