第一次把认证代码跑起来时,你会看到一个很有意思的分界:登录接口只接收一次用户名和密码,之后访问 /users/me、发布文章或查看审计记录时,客户端不再反复发送密码,而是在请求头里携带一枚短期访问令牌。后端收到令牌后,先验证它是不是自己签发的、有没有过期,再恢复当前用户,最后检查这个用户能不能做眼前这件事。
这条链里有两个问题,不能混在一起。身份验证回答“请求者是谁”,授权回答“这个身份能不能执行当前操作”。如果令牌无效,后端连身份都无法确认;如果令牌有效但缺少发布权限,后端知道你是谁,只是不放行。前者通常返回 401,后者通常返回 403。
这一章会搭一套能运行的最小认证链:用 pwdlib 处理密码哈希,用 OAuth2PasswordRequestForm 接收登录表单,用 PyJWT 签发和解码令牌,再用 FastAPI 的依赖系统把“当前用户”“权限范围”和“管理员角色”串起来。它适合用来理解单体应用中的访问令牌流程,但不是一套完整的身份服务。注册、找回密码、多因素认证、刷新令牌、撤销、设备管理、单点登录和密钥轮换,都需要在真实项目里继续补齐。

新建一个目录,安装这一章会用到的包:
uv init
uv add fastapi uvicorn pyjwt "pwdlib[argon2]" python-multipartpython-multipart 很容易漏装。OAuth2PasswordRequestForm 读取的是表单,而不是 JSON;FastAPI 解析表单时需要它。漏装后,应用在注册路由阶段就会提醒你安装相关依赖。
接下来给应用准备一个只存在于运行环境中的签名密钥。不要把密钥直接写进源码,也不要把它提交到版本库。可以先用系统提供的安全随机工具生成,再通过环境变量交给进程:
export APP_SECRET_KEY="<请放入本机生成的高熵随机值>"
uv run fastapi dev app.py示例里的尖括号是占位说明,不能当作密钥使用。生产环境应从部署平台的密钥管理功能注入,并设计密钥轮换方案。使用 HS256 时,签发和验证共用同一份密钥,任何拿到它的服务都能签发令牌,所以密钥能到达哪些进程,本身就是安全边界。
这一章后面的代码依次放进同一个 app.py。先创建应用对象:
from fastapi import FastAPI
app = FastAPI(title="课程内容 API")令牌签名不替代传输加密。登录表单和 Bearer 令牌都必须通过 HTTPS 传输,否则中间人仍可能直接窃取密码或令牌。
先别急着写登录接口。认证链真正的起点,是数据库里究竟保存什么。
密码不能以明文保存,也不应该用可逆加密后保存。登录时,服务端不需要“解密出原密码”;它只需要把这次输入交给专门的密码哈希算法,再与存储的哈希记录做安全比对。PasswordHash.recommended() 当前会选择适合密码存储的推荐算法。这样的算法故意消耗更多时间和内存,让攻击者拿到数据库后进行海量猜测的成本更高。
from pwdlib import PasswordHash
password_hash = PasswordHash.recommended()
stored_hash = password_hash.hash("用户刚设置的密码")
assert password_hash.verify("用户刚设置的密码", stored_hash) is True
assert password_hash.verify("另一串密码", stored_hash) is False你连续两次对同一密码调用 hash(),通常会得到不同的字符串。这不是不稳定,而是因为哈希记录里带有随机盐和算法参数。验证函数能从记录中读出这些参数,再完成同一套计算。

最直接的登录函数经常写成这样:查不到用户名就立刻返回,查得到才做耗时更长的密码校验。两条路径的耗时差异可能泄露“这个用户名是否存在”。所以我们准备一条占位哈希,用户不存在时也执行一次校验,让两种失败路径更接近。
DUMMY_HASH = password_hash.hash("只用于平衡校验耗时的占位密码")
def authenticate_user(username: str, password: str) -> UserInDB | None:
user = users_db.get(username)
if user is None:
password_hash.verify(password, DUMMY_HASH)
return None
if not password_hash.verify(password, user.hashed_password):
return None
return user这只是降低用户名枚举信号的一步,还不能替代登录限速、失败告警、异常行为检测和统一错误消息。安全措施通常是叠加的,不是找到一个函数就万事大吉。
同一个用户在应用里有两种视图。路由响应可以返回用户名、显示名和角色;密码哈希与内部权限集合只能留在服务端。把两者拆成模型,比每次响应前手动删除字段更可靠。
from pydantic import BaseModel, Field
class Token(BaseModel):
access_token: str
token_type: str
scope: str
class UserPublic(BaseModel):
username: str
display_name: str
role: str
disabled: bool = False
class UserInDB(UserPublic):
hashed_password:
课程中用内存字典代替数据库,启动时临时生成两条哈希记录:
users_db = {
"xiaolin": UserInDB(
username="xiaolin",
display_name="小林",
role="editor",
disabled=False,
hashed_password=password_hash.hash("course-demo-password"),
scopes={"profile:read", "articles:write"},
),
"admin": UserInDB(
username="admin"
这里的密码只为演示请求而存在,不能照搬到实际系统。真正的应用应在创建或修改密码时生成哈希,把哈希字符串持久化到数据库,并且永远不保留明文密码。响应模型必须使用 UserPublic,这样 hashed_password 和内部权限不会被接口意外序列化出去。
访问令牌由三部分组成:头部描述令牌类型与算法,载荷保存声明,签名用来发现内容是否被篡改。载荷经过的是适合传输的编码,不是保密加密。拿到令牌的人可以读取载荷,所以密码、密钥、身份证号等敏感信息不应该放进去。
我们只写三个与本章直接相关的声明:
sub 表示令牌主体,保存能唯一定位用户的字符串标识。exp 表示过期时间,限制令牌被盗后的可用窗口。scope 是用空格分隔的权限范围字符串,描述这枚令牌被授予的能力。
签发函数复制出一份载荷,加入使用 UTC 表示的过期时间,再用固定算法签名:
import os
from datetime import datetime, timedelta, timezone
import jwt
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 15
SECRET_KEY = os.environ["APP_SECRET_KEY"]
def create_access_token(
subject: str,
scopes: set[str],
expires_delta: timedelta | None = None,
) -> str:
datetime.now(timezone.utc) 得到带时区的 UTC 时间,避免服务器本地时区让过期判断变得含糊。十五分钟不是适合所有系统的固定答案;有效期要根据风险、使用体验以及有没有刷新和撤销机制一起决定。
解码时必须由服务端写死允许的算法,不能看见令牌头部声称什么算法就跟着用什么算法。还可以要求 exp 和 sub 必须出现:
from jwt.exceptions import InvalidTokenError
try:
payload = jwt.decode(
token,
SECRET_KEY,
algorithms=[ALGORITHM],
options={"require": ["exp", "sub"]},
)
except InvalidTokenError:
raise credentials_error签名错误、令牌格式损坏、过期时间不合法或令牌已过期,都会进入同一条认证失败路径。对外不需要透露“到底是哪一段签名不对”,否则只是在帮助攻击者调试请求。
签名只能证明令牌内容没有被未知方修改,并且验证方掌握正确密钥。它不会隐藏载荷,也不会自动提供退出登录、强制撤销或权限实时更新能力。
FastAPI 的 OAuth2PasswordRequestForm 按 OAuth2 密码表单的字段约定读取请求。客户端要发送 application/x-www-form-urlencoded,字段名是 username、password,可选的 scope 字段是一串由空格分隔的权限范围。
先声明令牌获取地址与可用权限范围:
from fastapi.security import OAuth2PasswordBearer
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl="token",
scopes={
"profile:read": "读取自己的资料",
"articles:write": "发布文章",
"audit:read": "读取审计记录",
},
)tokenUrl="token" 是相对地址,表示交互文档应把用户名和密码提交到 /token。它不会替你创建登录端点,也不会负责生成 JWT。OAuth2PasswordBearer 在受保护接口里负责从 Authorization: Bearer ... 中取出令牌,并把安全方案写进 OpenAPI。
然后实现登录端点:
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
@app.post("/token", response_model=Token)
async def login(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
) -> Token:
user = authenticate_user(form_data.username, form_data.password)
if user is None:
raise HTTPException(
status_code
这里没有把用户的全部权限自动塞进令牌,而是只签发本次申请且用户确实拥有的范围。这样,客户端可以申请一枚只读资料的令牌,也可以在确实需要发布时申请更大的范围。最小权限原则要落实在令牌签发处,不能等到业务接口才第一次想起。
在命令行发起登录请求:
curl -X POST http://127.0.0.1:8000/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'username=xiaolin&password=course-demo-password&scope=profile%3Aread%20articles%3Awrite'响应结构如下。令牌正文很长,这里不把可直接使用的凭据写进教材:
{
"access_token": "<令牌内容已省略>",
"token_type": "bearer",
"scope": "articles:write profile:read"
}如果密码写错,会看到:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer
{"detail":"用户名或密码错误"}这套密码表单流程适合用来学习协议字段和受信任客户端的最小实现。面向第三方客户端、浏览器单页应用或统一登录时,通常应使用成熟身份提供方和授权码流程,并配合 PKCE,而不是让第三方应用接触用户密码。
受保护接口不应该自己解析请求头、解码令牌、查用户、检查停用状态。我们把整条身份链放进依赖,路由最终只接收一个已经确认过的 UserPublic。
from fastapi import Depends, HTTPException, Security, status
from fastapi.security import SecurityScopes
async def get_current_user(
security_scopes: SecurityScopes,
token: Annotated[str, Depends(oauth2_scheme)],
) -> UserPublic:
authenticate_value = "Bearer"
if security_scopes.scopes:
authenticate_value += f' scope="{security_scopes.scope_str}"'
credentials_error = HTTPException(
status_code=status.
这段依赖按顺序做了六件事:提取 Bearer 令牌、验签与检查过期时间、读取 sub、查询当前用户、检查账户状态、检查权限范围。任何路由复用它,都得到相同的安全行为。
注意权限检查同时看了两个集合:令牌签发时声明的范围,以及数据库里用户现在仍然拥有的权限。假如管理员刚刚撤掉了小林的发布权限,旧令牌里可能还写着 articles:write;交集检查会立即阻止发布。这样会多一次用户查询,却换来了权限变更及时生效。若系统选择完全依赖令牌中的权限,就必须接受权限在令牌到期前可能仍然有效,并通过更短有效期或撤销机制管理风险。
普通的 Depends(get_current_user) 能建立身份依赖,但 Security 还能把端点所需的 scope 传给 SecurityScopes,并把要求写进 OpenAPI。
@app.get("/users/me", response_model=UserPublic)
async def read_me(
current_user: Annotated[
UserPublic,
Security(get_current_user, scopes=["profile:read"]),
],
) -> UserPublic:
return current_user
@app.post("/articles/publish")
async def publish_article(
current_user: Annotated[
UserPublic,
Security(get_current_user, scopes=[
从路由签名就能读出安全规则:资料接口需要 profile:read,发布接口需要 articles:write。业务函数不再出现手写的 if 权限判断,也不接触原始令牌。
拿登录响应中的令牌请求资料接口:
curl http://127.0.0.1:8000/users/me \
-H 'Authorization: Bearer <刚才取得的令牌>'返回:
HTTP/1.1 200 OK
{"username":"xiaolin","display_name":"小林","role":"editor","disabled":false}如果只申请 profile:read,再拿这枚令牌调用发布接口,身份能够验证,但缺少发布范围:
HTTP/1.1 403 Forbidden
{"detail":"权限范围不足"}scope 适合表达一个具体能力,例如“读取资料”“发布文章”;角色适合把一组能力和组织职责打包,例如编辑者、管理员。不要把角色字符串直接当成所有授权问题的答案。一个管理员能不能修改某篇文章,可能还要看租户、资源归属和数据状态。

我们给审计接口同时加上 audit:read 与管理员角色要求:
async def require_admin(
current_user: Annotated[
UserPublic,
Security(get_current_user, scopes=["audit:read"]),
],
) -> UserPublic:
if current_user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员角色",
)
return current_user
@app.get("/admin/audit")
调用顺序是先验证令牌,再检查 audit:read,最后检查 role == "admin"。任何一层失败都不会进入审计业务函数。进一步做资源级授权时,可以再加一个依赖,加载目标资源并判断 resource.owner_id == current_user.id。这比只设计几个越来越大的角色更贴近实际业务规则。
状态码不只是给人看的错误标签,它决定客户端下一步该做什么。
sub 找不到用户时,身份没有建立,返回 401,并带上 WWW-Authenticate: Bearer。403。
不携带令牌调用资料接口,会看到:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer
{"detail":"Not authenticated"}发送一枚已经过期的令牌,会进入我们统一的凭据错误:
HTTP/1.1 401 Unauthorized
{"detail":"无法验证登录凭据"}对外统一错误不代表内部完全不记录原因。服务端可以记录“过期”“签名失败”“权限缺失”等分类用于监控,但日志中不要记录密码、签名密钥或完整令牌。把完整 Bearer 令牌写进访问日志,相当于复制了一份可被重放的凭据。
只测“正确密码能登录”远远不够。认证代码最值得回归测试的,恰恰是它拒绝请求的方式。
from datetime import timedelta
from fastapi.testclient import TestClient
from app import app, create_access_token
client = TestClient(app)
def bearer(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def test_missing_token_is_401():
这一章的完整检查还应包括正确登录、错误密码、篡改令牌、过期令牌、未授权 scope 申请和角色不匹配。运行测试后得到:
........ [100%]
8 passed针对签名密钥的测试值只能由测试进程注入,不能复用生产密钥。测试创建的过期令牌也要显式设置负的时间差,避免依赖等待和系统时钟偶然性。
现在的应用已经能演示密码哈希、登录、短期令牌、当前用户、scope 和角色检查,但它仍然只是学习用的单体实现。

走向真实服务时,至少要继续处理这些边界:
HttpOnly、Secure、SameSite 与跨站请求防护;直接让脚本读取令牌时要重点防范脚本注入。如果服务面向多个应用、第三方客户端或企业统一登录,优先接入成熟身份提供方,让它负责登录、多因素认证、找回密码和令牌生命周期;FastAPI 服务专注于验证令牌并执行本地授权规则。自己实现一条教学链路能帮助你理解每一道门,但不等于应该从头自建整座身份平台。
判断一条受保护路由是否写清楚,可以按顺序问:凭据从哪里来,令牌验证了什么,当前用户从哪里读取,权限依据是令牌还是实时数据,失败应该返回四〇一还是四〇三。五个答案都能在依赖链里找到,业务函数才算真正摆脱了认证细节。