FastAPI 的安全体系是依赖注入的直接应用:OAuth2PasswordBearer 声明令牌来源,校验函数作为依赖挂在路由签名上,认证与授权构成逐层递进的依赖链。本节从方案选型讲起,完整实现密码哈希、JWT 签发与校验,并直视 JWT 的撤销难题与三种缓解手段。
阅读完本节,你应当能够:
服务对服务的机器调用(定时任务、内部微服务)用 API Key 最省事——一个随机字符串存库比对,配上依赖校验即可。管理脚本与内部工具用 HTTP Basic 可以接受,但凭据每次都明文传输(TLS 之下),不适合面向公网。面向用户的 Web 与移动端,主流是 OAuth2 密码流加 JWT:登录换令牌、令牌带签名与过期、服务端无状态校验——本节的主角。
Flask 生态里对应关系是 flask-jwt-extended 或手写装饰器,Django 生态是 rest_framework 的认证类。FastAPI 的差异不是能力而是位置:安全逻辑写在依赖里而不是装饰器或基类里,于是它能与参数校验、文档生成共享同一套签名机制——认证要求的 Authorization 头会自动出现在文档页的 Authorize 按钮背后,联调时前端直接在文档里登录测试。
from passlib.context import CryptContext pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto") def hash_password(plain: str) -> str: return pwd_ctx.hash(plain) def verify_password(plain: str, hashed: str) -> bool: return pwd_ctx.verify(plain, hashed)
bcrypt 自带盐、故意慢——这正是特性而非缺陷,暴力撞库的成本被算法本身抬高。数据库里只存哈希值,泄漏事故的破坏面因此收窄。对比事故形态:存明文的库泄漏等于全部密码泄露(用户跨站复用密码,连锁反应);存哈希的库泄漏,攻击者要逐条跑 bcrypt,多数弱密码仍会被算出,但强密码与时间窗都站在防守方。这一段没有任何框架差异,却是一切认证的地基,值得每次 code review 都看一眼。
登录接口完成凭据校验并签发令牌:
from datetime import datetime, timedelta, timezone from jose import jwt, JWTError SECRET = "CHANGE_ME_IN_ENV" ALGO = "HS256" def create_token(uid: int, expires_minutes: int = 30) -> str: payload = { "sub": str(uid), "exp": datetime.now(timezone.utc) + timedelta(minutes=expires_minutes), } return jwt.encode(payload, SECRET, algorithm=ALGO) @app.post("/token") def login(form: OAuth2PasswordRequestForm = Depends()): user = authenticate(form.username, form.password) # 内部调 verify_password if not user: raise HTTPException(status_code=401, detail="凭据错误") return {"access_token": create_token(user.id), "token_type": "bearer"}
校验侧构成依赖链:
from fastapi.security import OAuth2PasswordBearer oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") def get_current_user( token: str = Depends(oauth2_scheme), session=Depends(get_session), ): cred_error = HTTPException(status_code=401, detail="无效令牌", headers={"WWW-Authenticate": "Bearer"}) try: payload = jwt.decode(token, SECRET, algorithms=[ALGO]) except JWTError: raise cred_error user = session.get(User, int(payload["sub"])) if user is None: raise cred_error return user
OAuth2PasswordBearer 本身就是一个小依赖:从 Authorization 头提取 Bearer 令牌,缺失时返回 401。tokenUrl 参数告诉文档页"去哪个接口登录",于是文档页右上角的 Authorize 按钮可用——输入账号密码、拿令牌、此后所有受保护接口直接在文档里调试。文档协同不是花活,它让安全流程可以被非后端同学自助验证。
业务接口挂载权限层级:
def get_admin_user(user=Depends(get_current_user)): if not user.is_admin: raise HTTPException(status_code=403, detail="需要管理员权限") return user @app.delete("/users/{uid}") def delete_user(uid: int, admin=Depends(get_admin_user)): ...
链路全景:登录接口发令牌 → 客户端带令牌请求 → oauth2_scheme 提取 → jwt.decode 验签与查过期 → 数据库确认用户仍存在 → 权限依赖判定角色 → 业务函数。每一层只做一件事,每层失败返回各自的状态码(401 身份问题、403 权限问题),第 4 章的语义分层在这里完整落地。
无状态是 JWT 的核心优势——校验只需密钥与令牌本身,不查库、水平扩容无负担。但同一枚硬币的反面是撤销难:令牌一旦签出,在过期前始终有效。用户改密码、被封禁、离职,已发出的令牌还在有效期里"合法"游荡。三种缓解方案按重量排:
短有效期加刷新令牌。访问令牌只给 15 分钟,配一个长效的刷新令牌(可撤销,存库)。撤销的延迟上界就是访问令牌的余命——多数业务可以接受 15 分钟窗口,这是工业界默认方案。
服务端黑名单。封禁时把令牌的 jti(唯一标识)写进 Redis 黑名单,校验时多查一次。牺牲了一点无状态纯度,换取即时撤销。高频撤销场景(风控封号)必备。
版本号方案。用户表存 token_version,签发时写进 payload;改密码时 version 加一,旧令牌全部失效。一次全量失效的代价是每次校验多读一次用户——而我们的 get_current_user 本来就查库,等于零额外成本,中小项目首选。
⚠️ 常见坑三连:密钥硬编码进代码库(应从环境变量注入,泄漏即全域失守);HS256 的对称密钥用在多方验签场景(签发方与校验方不一时应换 RS256 非对称);payload 里放敏感信息(JWT 的 payload 只是 Base64 编码,不是加密,客户端可解出全部内容)。

OAuth2 的 scope 机制可以给令牌本身限定权限范围——签发时写入 scopes: ["orders:read"],校验依赖检查所需 scope 是否在令牌里。这解决了"同一用户的只读客户端与完全权限客户端"的区分问题:令牌的能力在签发时定格,而不是恒等于用户的全量权限。FastAPI 的 Depends 与 Security 组合可以让依赖声明自己需要的 scope,文档页同样会渲染 scope 选项。对外的开放平台 API 该用 scope,内部管理系统通常角色判定就够——粒度按暴露面选,过度设计同样是负担。
把本节内容压成一个可跟做的流程。准备三个文件位置(应用入口、认证模块、用户模型),依次完成六步:
第一步,装依赖:python-jose 处理 JWT、passlib 加 bcrypt 处理哈希。第二步,写密码工具(正文的两函数)与用户模型(含 password_hash 与 is_admin 字段)。第三步,写 token 签发与解析函数(正文的 create_token 与解码逻辑),密钥从环境变量读入(第 7 章配置管理的规范,此处先给一个无默认值的占位)。第四步,写 OAuth2PasswordBearer 实例与 get_current_user 依赖。第五步,写登录路由——OAuth2PasswordRequestForm 是现成的表单模型,username 与 password 两个字段直接可用,省掉自定义请求体。第六步,在一个业务接口上挂 get_current_user,打开文档页走一遍:Authorize 输入凭据、调受保护接口、看 200;再故意输错密码,看 401。
六步做完,你就拥有了一个生产形态的认证骨架——后续接数据库、加 scope、换 RS256 都是在这副骨架上做加法。对比"抄一个装饰器完事"的 Flask 起步方式,这套骨架的好处是每个环节你都写过一遍,出问题时知道边界在哪:令牌签得出但校验失败,查密钥与算法是否两端一致;401 但日志无异常,查 Authorization 头格式(Bearer 前缀、大小写);过期时间不生效,查 exp 是否用了带时区的 UTC 时间(裸的 datetime.now 是常见翻车点,时区缺失会让校验行为不确定)。
三个高频排查点先记在这里,它们覆盖了新手阶段八成的认证报障。
**问题:令牌放本地存储还是 Cookie?**经典之争的工程答案:纯 API 服务(移动端、服务间)没得选,客户端自己存;Web 前端两案并存——请求头加本地存储简单但脚本注入可窃取,HttpOnly Cookie 防脚本读取但要配套跨站请求防护与跨域凭据(4.3 节的 allow_credentials)。没有完美选项,按威胁模型选:面向外部输入复杂的管理后台,我倾向 HttpOnly Cookie;纯展示型应用,头部令牌够用。
**问题:JWT 里放角色还是每次查库?**放角色省一次查询,但角色变更后令牌里的旧角色仍在生效期内有效——撤销难题的角色版。折中方案:令牌放粗粒度声明(用户 id 加基本角色),细粒度权限实时查;或用短令牌让变更延迟上界等于令牌寿命。与正文的版本号方案同理,都是在查询成本与变更延迟之间定价。
**问题:密码规则(长度、复杂度)写在哪层?**请求体模型的字段约束(第 2 章)——格式规则属于输入契约;不能是最近用过的密码这类需要历史记录的规则在业务层。分界与第 2 章的判据完全一致(校验所需信息是否都在请求内),安全场景再次复用同一结构。
**问题:多个客户端类型(网页、小程序、开放平台)要几套认证?**一套内核多套入口:签发与校验的核心依赖共用,不同客户端走不同登录端点(表单、扫码、授权码),令牌里带客户端声明区分。FastAPI 的依赖链天然支持这种内核共享、外围分叉的结构——这也是依赖比中间件适合认证的又一佐证:多入口的认证在中间件层会退化成一堆条件分支。
认证链就绪,下一节处理"响应之后还要做的事":轻量后台任务与重型任务队列的边界。
认证体系的边界与延伸各说一条。边界:本节的方案覆盖"单一信任域"——你自己的用户、你的服务签发的令牌。跨信任域的场景(让第三方应用代表用户访问你的 API)需要完整的 OAuth2 授权码流程(用户跳转授权、回调换令牌),那是一套独立的多方协议,本节的密码流是它的简化子集。延伸:令牌的形态不必是 JWT——服务器端会话(令牌只是查库的钥匙)在单体架构里依然合理且撤销天然容易,JWT 的价值在无状态横向扩展,单体服务不必为了"现代"而用它。选型的锚点还是第 1 章那句话:对得上问题的量级。
安全知识的维护也有特殊性:其他章节的知识半衰期以年计,安全的攻防知识以月计更新。本节的机制层(哈希、签名、依赖链)稳定,但具体的攻击面(新的绕过手法、依赖库的漏洞)需要持续关注——订阅依赖库的安全通告、定期升级含安全补丁的版本,这两个习惯比任何具体技巧都重要。
补一段与测试章的衔接实操:认证链写完的当天就该有测试。最小集四条——登录成功拿到令牌、错误密码得 401、无令牌访问受保护接口得 401、过期令牌得 401(构造一个已过期的令牌直接调依赖或绕过时间)。四条测试把签发与校验的两端都钉住,后续任何人改动认证逻辑都会立刻踩红。把"安全代码与安全测试同天提交"当团队规约,能拦住绝大多数"先上线后补测"的侥幸。
把撤销三方案的适用条件抄进团队笔记,选型会上的十分钟争论可以简化成一次对表。