FastAPI-安全与认证

这篇讲什么

  • 一条完整的认证链:密码哈希 → 登录发 JWT → OAuth2PasswordBearer 解 token → Security 查 scopes,每一步都在本机跑通并贴了实测输出。
  • 官方教程现在推荐 pwdlib,不再推荐 passlib:实测 passlib 1.7.4 配 bcrypt 5.0.0 直接 ValueError,一次哈希都做不出来;pwdlib 0.3.1 的 verify() 返回布尔值,还有 verify_and_update() 支持换算法。
  • JWT 用 PyJWT 就够(python-jose 实测也能跑,但已不是首选);decode() 必须显式传 algorithms,短密钥会告警,过期抛 ExpiredSignatureError。

密码哈希怎么选

  • 官方推荐 pwdlib(早期教程写的 passlib 已经不能用了,见下一节),装法:

    1
    pip install "pwdlib[argon2,bcrypt]"
    1
    Would install pwdlib-0.3.1
  • 最小可用写法,实测:

    hashing.py
    1
    2
    3
    4
    5
    6
    7
    8
    from pwdlib import PasswordHash

    password_hash = PasswordHash.recommended()

    hashed = password_hash.hash("P@ssw0rd-2026")
    print(hashed[:20])
    print(password_hash.verify("P@ssw0rd-2026", hashed))
    print(password_hash.verify("bad", hashed))
    1
    2
    3
    pwdlib 0.3.1
    recommended() 算法前缀: $argon2id$v=19$m=655 | 校验 True | 错密码 False
    hash 长度: 97
    • verify() 返回布尔值,不抛异常,写起来比 argon2-cffi 干净(后者错密码抛 VerifyMismatchError)。
    • recommended() 当前是 argon2id(m=65536,t=3,p=4),内存硬度足够,是 OWASP 现在的首选。
  • 换算法/升级参数不用停机,实测多 handler 与 verify_and_update():

    upgrade.py
    1
    2
    3
    4
    5
    6
    7
    8
    from pwdlib import PasswordHash
    from pwdlib.hashers.argon2 import Argon2Hasher
    from pwdlib.hashers.bcrypt import BcryptHasher

    # 新哈希用第一个 handler,旧哈希仍能校验
    password_hash = PasswordHash((Argon2Hasher(), BcryptHasher()))

    ok, new_hash = password_hash.verify_and_update("abc", old_bcrypt_hash)
    1
    2
    3
    多算法: 新哈希 $argon2id$v=19 | 校 bcrypt 旧哈希 True
    verify_and_update(旧哈希) -> (True, '$argon2id$v=19$m=65536,t=3,p=4$...')
    verify_and_update(已是新算法) -> (True, None) # None 表示不需要重写
    • 这就是「老用户登录时顺手把 bcrypt 哈希升级成 argon2id」的标准做法:拿到 new_hash 就写回数据库。
    • BcryptHasher(rounds=…, prefix=…)、Argon2Hasher(time_cost, memory_cost, parallelism, hash_len, salt_len, type) 是可调参数,别在没测过吞吐前把 cost 调太高。
  • 三种库本机横向实测:

    1
    2
    3
    4
    5
    pwdlib 0.3.1 + argon2id  -> hash 97 字符,verify 返回布尔,支持 verify_and_update
    argon2-cffi 25.1.0 -> hash 97 字符,$argon2id$v=19$m=65536,t=3,p=4$...
    错密码抛 VerifyMismatchError;方法是 check_needs_rehash
    bcrypt 5.0.0 直接用 -> hash 60 字符 $2b$12$...,hash 0.194s / check 0.197s(rounds=12)
    passlib 1.7.4 -> 完全不可用,见下一节

passlib 为什么不能用

  • 官方老教程里那段 CryptContext(schemes=["bcrypt"]) 在本机直接炸,而且报错信息跟「密码长度」有关,很容易误判成自己的问题:

    1
    2
    ValueError: password cannot be longer than 72 bytes,
    truncate manually if necessary (e.g. my_password[:72])
    • 真正的原因在 passlib 的源码里:它加载 bcrypt 后端时会做一次「wrap bug 探测」,拿一个超过 72 字节的密码去哈希(passlib/handlers/bcrypt.py:421 detect_wrap_bug)。
    • bcrypt 从 4.1 起不再静默截断,而是抛 ValueError,于是探测本身就失败了,任何一次 ctx.hash() 都报这个错。
    • 它还会先打一条 AttributeError: module 'bcrypt' has no attribute '__about__'(passlib 用旧字段读版本号),日志里那行 (trapped) error reading bcrypt version 就是同一件事。
    • passlib 最后一次发版是 1.7.4(2020 年),社区在 FastAPI 仓库里也提过这个issue(见参考),所以结论是:别再用 passlib,用 pwdlib。
  • bcrypt 的 72 字节上限本身是真实存在的(不是 passlib 的锅),实测直接调用也一样:

    1
    2
    bcrypt.hashpw(b"x" * 100, bcrypt.gensalt())
    -> ValueError password cannot be longer than 72 bytes

    所以「超长密码/密码里带 emoji 被截断」这类问题要靠在注册侧限制长度或改用 argon2(pwdlib 的 argon2 handler 没有 72 字节限制),而不是自己截断。

JWT 的用法与坑

  • 官方教程用的是 python-jose,实测它能跑(3.5.0,纯 Python,Python 3.14 上没问题):

    1
    python-jose 3.5.0 -> 能用,长度 121 | 解回 {'sub': 'lilei', 'role': 'admin'}

    但 PyJWT 更主流、维护更勤(本机 2.15.1),Flask/Django 那两篇也统一用 PyJWT,本篇按 PyJWT 写。

  • PyJWT 的四个实测行为:

    jwt_demo.py
    1
    2
    3
    4
    5
    6
    import jwt
    from datetime import datetime, timedelta, timezone

    KEY = "0123456789abcdef0123456789abcdef" # 至少 32 字节,走环境变量
    token = jwt.encode({"sub": "lilei", "role": "admin"}, KEY, algorithm="HS256")
    data = jwt.decode(token, KEY, algorithms=["HS256"])
    1
    2
    3
    4
    5
    6
    7
    encode -> 长度 121 | header {'alg': 'HS256', 'typ': 'JWT'}
    decode -> {'sub': 'lilei', 'role': 'admin'}
    不传 algorithms -> DecodeError: It is required that you pass in a value for the
    "algorithms" argument when calling decode()
    错密钥 -> InvalidSignatureError: Signature verification failed
    密钥只有 5 字节 -> 告警 InsecureKeyLengthWarning: The HMAC key is 5 bytes long,
    which is below the minimum recommended length of 32 bytes for SHA256
    • algorithms 必须显式传,这是防「算法混淆攻击」的关键,不传直接报错(不是警告)。
    • 密钥短于 32 字节会告警:教程里 SECRET_KEY = "secret" 这种写法在新版 PyJWT 上会被点名,生产要用足够长的随机串并从环境变量注入。
  • 过期与常见声明实测:

    jwt_claims.py
    1
    2
    3
    4
    now = datetime.now(timezone.utc)
    token = jwt.encode({"sub": "lilei", "iat": now,
    "exp": now + timedelta(minutes=30), "jti": "固定 id"},
    KEY, algorithm="HS256")
    1
    2
    3
    4
    解出的声明: {'sub': 'lilei', 'iat': '1793971187', 'exp': '1793972987', 'jti': '固定 id'}
    过期后 -> ExpiredSignatureError: Signature has expired
    leeway=10 -> 能过(容忍 10 秒时钟偏差)
    要求 audience 但 token 里没有 -> MissingRequiredClaimError: Token is missing the "aud" claim
    • exp 是必须放的,否则 token 永久有效;iat 便于审计,jti 是做黑名单/单次使用的抓手。
    • 多实例部署时服务器时钟要同步,否则会出现「刚发的 token 立刻算过期」,leeway 是兜底不是解法。
    • 用 audience= 区分「登录 token / 刷新 token / 内部服务 token」,比在 payload 里塞 type 字段更硬。
  • 别把密钥和敏感信息放进 payload:JWT 只是 base64url + 签名,任何人拿到 token 都能读出内容,实测连库都不用:

    jwt_read.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    import base64
    import json

    head, body, sig = token.split(".")


    def b64(s):
    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))


    print(json.loads(b64(body)))
    1
    2
    3
    token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJsaWxlaSJ9....
    裸 base64 解 payload: {'sub': 'lilei', 'role': 'admin'}
    get_unverified_header: {'alg': 'HS256', 'typ': 'JWT'}

    签名只保证「不被篡改」,不保证「内容保密」。要保密就得加密或改用会话。

OAuth2 密码模式全流程

  • 登录接口用官方给的表单依赖,别自己拆字段:

    auth.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    from typing import Annotated

    import jwt
    from fastapi import Depends, FastAPI, HTTPException, status
    from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
    from pwdlib import PasswordHash

    app = FastAPI()
    password_hash = PasswordHash.recommended()
    KEY = "从环境变量读,至少 32 字节"
    USERS = {"lilei": {"password_hash": password_hash.hash("good-pass"), "role": "user"}}

    oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/login")


    @app.post("/login")
    def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]):
    user = USERS.get(form.username)
    if not user or not password_hash.verify(form.password, user["password_hash"]):
    raise HTTPException(status.HTTP_401_UNAUTHORIZED, "用户名或密码不对",
    headers={"WWW-Authenticate": "Bearer"})
    return {"access_token": jwt.encode({"sub": form.username}, KEY, algorithm="HS256"),
    "token_type": "bearer"}
    1
    2
    3
    4
    5
    POST /login (urlencoded username=lilei&password=good-pass)
    -> 200 {'access_token': 'eyJhbGciOiJIUzI1NiIs...', 'token_type': 'bearer'}
    POST /login 错密码 -> 401
    POST /login 用 JSON 发 -> 422
    POST /login 缺 username -> 422
    • 必须是 application/x-www-form-urlencoded,实测用 JSON 发就是 422;文档里这个接口的 requestBody.content 也只有这一种类型。

    • OAuth2PasswordRequestForm 实际收的字段实测:

      1
      2
      3
      {'username': 'u', 'password_len': 1, 'scopes': [], 'grant_type': None,
      'client_id': None, 'client_secret': None}
      带 scope=read write 时 -> scopes: ['read', 'write']

      也就是 username、password、scope(可重复)、grant_type、client_id、client_secret;要加自定义字段(比如租户号)就继承它加一个表单参数。

  • 受保护接口靠一个「解 token」的依赖:

    current_user.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    async def current_user(token: Annotated[str, Depends(oauth2_scheme)]):
    try:
    data = jwt.decode(token, KEY, algorithms=["HS256"])
    except jwt.ExpiredSignatureError:
    raise HTTPException(401, "token 过期")
    except jwt.InvalidTokenError:
    raise HTTPException(401, "token 无效")
    if data["sub"] not in USERS:
    raise HTTPException(401, "用户不存在")
    return data


    @app.get("/me")
    def me(user: Annotated[dict, Depends(current_user)]):
    return {"sub": user["sub"]}
    1
    2
    3
    带 token  -> {'sub': 'lilei'}
    不带 token -> 401 {'detail': 'Not authenticated'} | WWW-Authenticate: Bearer
    坏 token -> 401
    • OAuth2PasswordBearer 只负责「从 Authorization: Bearer xxx 里取出字符串」,它不校验内容,校验在 jwt.decode。
    • 401 一定要带 WWW-Authenticate: Bearer,Swagger UI 的 Authorize 按钮和浏览器行为都依赖它;实测 HTTPBearer/OAuth2PasswordBearer 自动帮你带上。
    • 想让「未登录」不报错而是走匿名分支,构造时传 OAuth2PasswordBearer(tokenUrl="/login", auto_error=False),然后自己判 None。

scopes 与权限

  • 声明可用 scope,并在依赖里检查:

    scopes_demo.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    from fastapi import Security, SecurityScopes

    scheme2 = OAuth2PasswordBearer(tokenUrl="/login2", scopes={"read": "读", "write": "写"})


    async def user_with_scopes(scopes: SecurityScopes,
    token: Annotated[str, Depends(scheme2)]):
    data = jwt.decode(token, KEY, algorithms=["HS256"])
    have = set((data.get("scope") or "").split())
    if not set(scopes.scopes) <= have:
    raise HTTPException(403, "权限不足", headers={"WWW-Authenticate": "Bearer"})
    return data


    @app.get("/need-write")
    def need_write(u: Annotated[dict, Security(user_with_scopes, scopes=["write"])]):
    return {"ok": True}
    1
    2
    3
    4
    注入进来的 SecurityScopes 实例属性: {'scopes': ['write'], 'scope_str': 'write'}
    只有 read 打 need-write -> 403 {'detail': '权限不足'}
    只有 read 打 need-read -> {'ok': True}
    read+write 打 need-write -> {'ok': True}
    • ⚠️ SecurityScopes 在 0.162 上的属性是 scopes 和 scope_str,老教程写的 scopes.required_scopes 实测直接 AttributeError: 'SecurityScopes' object has no attribute 'required_scopes'。
    • SecurityScopes 是按类型注入的,不要写成 Depends(SecurityScopes)(那样会把它当依赖函数解析,实测拿到的是 token 字符串)。
    • Security(依赖, scopes=[...]) 会把需求累加到 scopes.scopes 里,所以一个接口挂多个依赖时,权限是自动并集。
  • 文档里能看到 scopes 声明,实测:

    1
    2
    3
    4
    securitySchemes: {"OAuth2PasswordBearer": {"type": "oauth2",
    "flows": {"password": {"scopes": {"read": "读", "write": "写"},
    "tokenUrl": "/login2"}}}}
    /need-write 的 security: [{"OAuth2PasswordBearer": ["write"]}]

    前端/第三方照着这份声明就能知道「要什么权限、去哪拿 token」。

其它认证方案

  • fastapi.security 里的现成件,实测行为:

    1
    2
    3
    4
    5
    6
    7
    HTTPBasic      无凭据 -> 401 | WWW-Authenticate: Basic
    带凭据 -> {'who': 'lilei'}(base64 解出的用户名)
    坏 base64 -> 401
    APIKeyHeader 带 X-API-Key -> {'k': 'k1'} | 缺失 -> 401
    APIKeyQuery 带 ?api_key=k2 -> {'k': 'k2'} | 缺失 -> 401
    APIKeyCookie 带 cookie session=s3 -> {'k': 's3'} | 缺失 -> 401
    HTTPBearer 带 Bearer t -> {'token': 't'} | 缺失 -> 401 {'detail': 'Not authenticated'}
    • API Key 三种位置都可用,但查询串会进访问日志、Referer 和浏览器历史,对外接口优先 Header。
    • HTTPBasic 每次请求都带口令,必须全站 HTTPS;适合内部服务,不适合浏览器端长期用。
    • HTTPBearer(auto_error=False) 适合「有 token 就用、没有就匿名」的场景。
  • 服务端会话(cookie + 签名)用 Starlette 的 SessionMiddleware,实测:

    session.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    from starlette.middleware.sessions import SessionMiddleware

    app.add_middleware(SessionMiddleware, secret_key=KEY,
    https_only=True, same_site="strict")


    @app.get("/sess")
    def sess(request: Request):
    request.session["n"] = request.session.get("n", 0) + 1
    return {"n": request.session["n"]}
    1
    2
    3
    4
    第一次: {'n': 1} | Set-Cookie: session=eyJuIjogMX0=.au3T8w.VaZsbs...; path=/
    第二次(自动带 cookie): {'n': 2}
    https_only + same_site="strict" ->
    session=...; path=/; Max-Age=1209600; httponly; samesite=strict; secure
    • 两个实测坑:它不在 fastapi.middleware 下(from fastapi.middleware.sessions import ... 会 ModuleNotFoundError,要 from starlette.middleware.sessions),而且依赖 itsdangerous,fastapi[standard] 不带它,要 pip install itsdangerous(实测 2.2.0)。
    • 参数名是 same_site,写成 samesite 会 TypeError: unexpected keyword argument 'samesite'。
    • 会话内容只是 base64 + 签名,不加密(eyJuIjogMX0= 一眼能解),别往里放敏感数据——这条和 Tornado-应用安全 里安全 cookie 的结论一样。

传输与浏览器侧

  • CORS 的正确配置组合,实测细节见 FastAPI-依赖注入与异常中间件的 CORS 一节,这里只强调安全相关的三条:
    • allow_credentials=True 时 allow_origins 不能用 "*",必须列具体源,否则浏览器直接拒。
    • 带凭据的接口不要开 allow_headers=["*"] 图省事,按实际用到的头列。
    • 预检失败是 400(实测 Disallowed CORS method),不是 401,排查时别看错方向。
  • token 存哪里,是面试常问的取舍,实测事实是:
    • localStorage + Authorization 头:XSS 一旦被打通,token 直接被偷走;不会被 CSRF 打。
    • HttpOnly cookie(set_cookie(..., httponly=True, samesite="lax", secure=True)):JS 读不到,抗 XSS 窃取;但要靠 CSRF 防护兜着(FastAPI 没有内置 CSRF 中间件,靠 SameSite + 校验 Origin)。
    • 折中:access token 放内存变量 + refresh token 放 HttpOnly cookie,这也是多数现代前端的实际做法。
  • HTTPS 是前提,不是可选项:
    • 生产要在 nginx 侧终结 TLS 并开 HSTS,写法见 Nginx。
    • 应用侧要知道「自己是代理后面」,否则 request.url 会生成 http:// 的重定向与 OAuth 回调地址,--proxy-headers 与 --root-path 的实测写法见 FastAPI-WebSocket、测试与部署。
  • 限流不在框架里,fastapi 没有内置 rate limit。常见做法是 nginx limit_req(见 Nginx)或中间件 + Redis 计数(见 Redis入门),第三方 slowapi 只是把 Flask-Limiter 包了一层,依赖 Redis 时才划算。
  • 密码相关字段一律不要出现在响应里:response_model 是这道防线(实测裁掉了 password),别指望「前端不显示」,见 FastAPI-响应模型与OpenAPI文档。

参考

本文结束 感谢您的阅读
正在加载今日诗词....