首页
/ FastAPI 安全实战:OAuth2 密码流 + Argon2 密码哈希 + PyJWT Bearer 令牌完整实现

FastAPI 安全实战:OAuth2 密码流 + Argon2 密码哈希 + PyJWT Bearer 令牌完整实现

2026-09-06 18:54:27作者:郜逊炳

本文将带你基于 FastAPI 官方安全教程构建一套真正可投入生产的认证体系:使用 pwdlib(Argon2)对密码做单向哈希存储,使用 PyJWT 签发与校验带过期时间的 JWT 访问令牌,并通过 OAuth2PasswordBearer + 依赖注入把“当前登录用户”安全地注入到每个受保护接口。读完你就能在自己的 FastAPI 应用里复刻这套“用户名/密码 → 令牌 → 受保护接口”的完整闭环,并能对照本仓库源码与测试用例理解每一层的底层原理。


1. 教程在安全系列中的位置

本节内容位于 FastAPI 官方文档安全教程的收官位置,它建立在前三章之上:

  1. first-steps.md:介绍 OAuth2PasswordBearer,建立 oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") 安全方案;
  2. simple-oauth2.md:引入 OAuth2PasswordRequestForm,实现 /token 端点与 get_current_user / get_current_active_user 依赖——但当时密码只是“假哈希”("fakehashed" + password),令牌就是用户名本身,完全不安全
  3. get-current-user.md:把 token 从 str 提升为 Pydantic User 模型并注入路径操作函数;
  4. 本节 oauth2-jwt.md:把“假哈希/假令牌”升级为 Argon2 密码哈希 + JWT 签名令牌,让整套流程真正可用于生产。

对应上一章的完整示例见 tutorial003_an_py310.py,其中 fake_hash_passwordfake_decode_token 都明确标注了“This doesn't provide any security at all / Check the next version”,这正是本节要替换的部分。

本仓库中本节完整的可运行源码是 tutorial004_an_py310.py(还有不带 Annotated 写法的 tutorial004_py310.py),配套测试位于 test_tutorial004.py。本文所有代码均以该源文件为准。


2. 关于 JWT(JSON Web Tokens)

JWT 是“JSON Web Tokens”的缩写,是一套把一个 JSON 对象编码进一长串无空格的稠密字符串的标准。一个典型的 JWT 长这样:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

该字符串由句点分隔为三个 Base64Url 片段,分别对应:

片段 内容 说明
Header {"alg":"HS256","typ":"JWT"} 声明签名算法(此处为 HS256)
Payload {"sub":"1234567890","name":"John Doe","iat":1516239022} 携带声明(claims),如主体 sub、签发时间 iat
Signature 签名值 由 Header+Payload 与密钥计算得到,用于防篡改校验

理解 JWT 需要抓住两条关键性质:

  • 它没有加密:任何人都能直接解码中间那段载荷、恢复其中的信息。因此不要把密码等机密信息放进 token
  • 但它是有签名的:当你收到一个自己签发过的 token 时,可以验证它确实出自你手。若用户或第三方试图篡改载荷(比如把过期时间改长),签名将无法匹配,服务端能够立刻发现。

基于签名机制,你可以签发一个“有效期 1 周”的 token:第二天用户带着旧 token 回来,你知道他仍处于登录态;一周后 token 过期,用户不再被授权、需要重新登录换取新 token。

提示:文档本身建议读者去 jwt.io 这类在线工具拆解体验 token 的三段结构。你可以用仓库中生成的真实 token,配合 PyJWTjwt.decode(..., options={"verify_signature": False}) 或在线解码工具查看其 Payload。


3. 安装 PyJWT:签发与校验令牌的底层库

要在 Python 中生成与校验 JWT,需要安装 PyJWT。使用 uv 添加到项目:

$ uv add pyjwt

---> 100%

几点补充说明:

  • 本仓库的 pyproject.toml(依赖列表 168–169 行附近)将文档示例所需的最低版本固定为 pyjwt >= 2.9.0,与该教程一致;
  • 若你打算使用 RSA、ECDSA 等非对称数字签名算法,需要额外安装其密码学底层依赖,即 pyjwt[crypto]
  • 本教程采用对称算法 HS256,签发与校验使用同一个 SECRET_KEY,因此不需要额外的 cryptography 依赖,安装普通 pyjwt 即可。

在代码中使用到的 API 主要是两个(详见后文完整代码):

  • jwt.encode(payload_dict, SECRET_KEY, algorithm=ALGORITHM):签发令牌;
  • jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]):校验并解析令牌;校验失败(签名不符、过期、格式非法等)会抛出 InvalidTokenError 及其子类。

4. 密码哈希:为什么不能明文存密码

“哈希(Hashing)”是指把某段内容(这里就是密码)转换为一段看起来像乱码的字节串。它有两条核心特性:

  1. 确定性:只要输入完全相同的密码,就总是得到完全相同的“乱码”;
  2. 单向性:无法从乱码反推出原始密码。

为什么要用哈希保存密码

假设你的数据库被盗:

  • 如果存的是明文,小偷直接拿到所有用户的真实密码;
  • 如果存的是哈希,小偷拿到的只是一堆不可逆的哈希值。

由于大量用户喜欢在不同网站复用同一密码,明文泄露意味着攻击者可以把这些密码拿去撞库其他系统,造成连锁危害;而哈希存储让这种“跨系统撞库”的破坏力被大幅削弱。

需要特别强调的是:哈希不等于“弱加密”。本节采用的 Argon2 是专门为口令设计的“慢哈希”算法,通过引入时间/内存代价来抵御 GPU 暴力破解(详见第 5、6 节)。


5. 安装 pwdlib[argon2]:密码哈希工具

pwdlib 是一个优秀的 Python 密码哈希处理包,支持多种安全哈希算法及其配套工具。官方推荐算法是 Argon2

使用 uv 安装带 Argon2 支持的版本:

$ uv add "pwdlib[argon2]"

---> 100%

同样地,仓库 pyproject.toml 中将其最低版本固定为 pwdlib[argon2] >= 0.2.1

关于算法互操作性的两个重要提示:

  • pwdlib 还支持 bcrypt,但不包含旧式/遗留算法;若要读取那些用旧算法(如 passlib 生成的 MD5/SHA1 口令等)产生的过期哈希,官方建议配合 passlib 使用;
  • 跨框架共享数据:借助 pwdlib,你可以配置它读取由 DjangoFlask 安全插件等系统生成的密码哈希。这意味着 FastAPI 与 Django 可以共享同一个数据库中的用户口令数据、支持渐进式迁移——Django 应用与 FastAPI 应用的用户可以同时在两套系统上登录。

6. 哈希与校验密码:PasswordHash + 防计时攻击的“假哈希”技巧

核心思路是创建一个全局的 PasswordHash 实例(使用推荐配置 Argon2),再由它派生出三个工具函数:get_password_hash(入库前哈希)、verify_password(登录时比对)、authenticate_user(按用户名取用户并校验口令)。

对应源码见 tutorial004_an_py310.py 的关键片段:

from pwdlib import PasswordHash

# ...(其他 import 与模型定义见第 8 节完整代码)

password_hash = PasswordHash.recommended()

DUMMY_HASH = password_hash.hash("dummypassword")


def verify_password(plain_password, hashed_password):
    return password_hash.verify(plain_password, hashed_password)


def get_password_hash(password):
    return password_hash.hash(password)


def get_user(db, username: str):
    if username in db:
        user_dict = db[username]
        return UserInDB(**user_dict)


def authenticate_user(fake_db, username: str, password: str):
    user = get_user(fake_db, username)
    if not user:
        verify_password(password, DUMMY_HASH)
        return False
    if not verify_password(password, user.hashed_password):
        return False
    return user

逐段解读:

  • password_hash = PasswordHash.recommended():以推荐参数创建实例。pwdlib 的 Argon2 推荐配置落在 argon2id 变体上。仓库示例数据库中存储的哈希 "$argon2id$v=19$m=65536,t=3,p=4$wagCPXjifgvUFBzq4hqe3w$CYaIb8sB+wtD+Vu/P4uod1+Qof8h+1g7bbDlBID48Rc" 即揭示了其参数编码:v=19 为算法版本、m=65536 内存代价(KiB)、t=3 迭代次数、p=4 并行度;
  • get_password_hash:注册/改密时对明文密码做哈希,产出的字符串(含算法标识与随机盐)可直接入库;
  • verify_password:登录时把用户提交的明文与库中哈希比对——PasswordHash.verify 会从哈希串解析算法与盐再重新计算,因此即使日后换算法,旧哈希依然可验证
  • authenticate_user:完整登录校验。关键细节在 if not user 分支——当用户名不存在时,它仍会调用一次 verify_password(password, DUMMY_HASH) 对预先算好的“假哈希”做一次同样开销的比对。这样无论用户名是否合法,该端点耗时都大致相同,从而阻止攻击者通过响应时间差异(timing attack)枚举合法用户名

提示:注意在代码中任何地方都找不到明文密码 "secret"——数据库里只有 fake_users_db["johndoe"]["hashed_password"] 这段 Argon2 哈希(见上文示例值)。"secret" 只在教程演示登录时作为输入出现。


7. 处理 JWT 令牌:密钥、算法、过期时间与签发函数

接下来为令牌部分补充所需配置与工具函数。第一步是生成一个随机安全密钥作为 JWT 签名密钥,在终端执行:

$ openssl rand -hex 32

09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7

把输出复制到 SECRET_KEY 变量——务必用自己生成的新密钥,不要使用示例中的那个

对应的配置与工具函数(见源码第 4、7、13–15、29–31、82–90 行):

import jwt
from jwt.exceptions import InvalidTokenError

# to get a string like this run:
# openssl rand -hex 32
SECRET_KEY = "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

# ...

class Token(BaseModel):
    access_token: str
    token_type: str


class TokenData(BaseModel):
    username: str | None = None


def create_access_token(data: dict, expires_delta: timedelta | None = None):
    to_encode = data.copy()
    if expires_delta:
        expire = datetime.now(timezone.utc) + expires_delta
    else:
        expire = datetime.now(timezone.utc) + timedelta(minutes=15)
    to_encode.update({"exp": expire})
    encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
    return encoded_jwt

要点解析:

  • SECRET_KEY:HS256 是对称算法,签名与校验共用此密钥。openssl rand -hex 32 产生 64 个十六进制字符(256 位熵),生产环境应放到环境变量/密钥管理服务中,严禁硬编码入库
  • ALGORITHM = "HS256":与 JWT Header 中的 alg 对应;jwt.decode(..., algorithms=[ALGORITHM]) 显式指定白名单,可防止算法混淆类攻击;
  • ACCESS_TOKEN_EXPIRE_MINUTES = 30:业务层令牌有效期为 30 分钟(create_access_token 内置兜底为 15 分钟);
  • 过期时间使用 UTCdatetime.now(timezone.utc)(Python 3.11 起也可用 datetime.now(UTC))——避免本地时区造成的时间偏差,这是签发令牌的通用最佳实践;
  • exp(expiration)是 JWT 注册声明之一,PyJWT 在校验时会自动比对当前时间;TokenDataToken 是两个 Pydantic 模型,分别承载“解码后的令牌数据”与“/token 响应的载荷结构”。

8. 更新依赖:让 get_current_user 真正解码并校验 JWT

在简单教程里,get_current_user 拿到的是 str 类型的 token,并且直接当作用户名用(fake_decode_token)。现在改为:接收同样的 token → 用 SECRET_KEY 解码 → 提取 sub → 查库返回用户;任何一步失败立即抛 401(见源码第 93–110 行):

async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        username = payload.get("sub")
        if username is None:
            raise credentials_exception
        token_data = TokenData(username=username)
    except InvalidTokenError:
        raise credentials_exception
    user = get_user(fake_users_db, username=token_data.username)
    if user is None:
        raise credentials_exception
    return user

值得注意的三点:

  1. 统一走 InvalidTokenErrorPyJWT 对过期、签名不匹配、格式错误、字段非法等统统抛出 jwt.exceptions.InvalidTokenError 及其子类。此处只捕获这一个异常基类即可覆盖所有失败场景;
  2. WWW-Authenticate: Bearer 响应头:HTTP 规范要求 401 响应携带 WWW-Authenticate 头,Bearer 方案下其值应为 Bearer。虽然省略也能工作,但按规范补齐有利于各类 OAuth2 工具链识别;
  3. token 无 sub、用户名查无此人分别走两条分支,最终都返回同一个 401 与统一文案 "Could not validate credentials",避免向攻击者泄露“用户名是否存在”的信息。

get_current_active_user 保持不变,作为内层依赖过滤被禁用账号:

async def get_current_active_user(
    current_user: Annotated[User, Depends(get_current_user)],
):
    if current_user.disabled:
        raise HTTPException(status_code=400, detail="Inactive user")
    return current_user

9. 更新 /token 路径操作:签发真正的 JWT

登录端点从“直接回传用户名”改为:认证通过后生成带过期时间的 timedelta,构造 {"sub": 用户名} 载荷调用 create_access_token,最终返回符合 OAuth2 规范的 Token 响应模型(见源码第 121–136 行):

@app.post("/token")
async def login_for_access_token(
    form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
) -> Token:
    user = authenticate_user(fake_users_db, form_data.username, form_data.password)
    if not user:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password",
            headers={"WWW-Authenticate": "Bearer"},
        )
    access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    access_token = create_access_token(
        data={"sub": user.username}, expires_delta=access_token_expires
    )
    return Token(access_token=access_token, token_type="bearer")

逐层说明:

  • OAuth2PasswordRequestForm 负责从 application/x-www-form-urlencoded 表单中解析 usernamepassword(还可选解析 scopegrant_typeclient_idclient_secret),它的具体语义在前一章 simple-oauth2.md 中有完整讲解;
  • authenticate_user 失败统一返回 401 + "Incorrect username or password"不区分是用户名不存在还是密码错误(配合第 6 节的假哈希比对,从时间与文案两个维度防枚举);
  • token 载荷只放入 sub(用户名)——这符合“令牌中不放敏感信息”的 JWT 使用准则;
  • 返回体用 Token Pydantic 模型约束,确保 JSON 中一定包含规范要求的 access_tokentoken_type 两个键,token_type"bearer"

受保护的示例接口则保持极简形态,全部安全逻辑沉淀在依赖层:

@app.get("/users/me/")
async def read_users_me(
    current_user: Annotated[User, Depends(get_current_active_user)],
) -> User:
    return current_user

10. 完整可运行代码汇总

将以上各部分拼接后,即得到一份完整、可独立运行的示例应用(含全部 import 与 Pydantic 模型,基于 Python 3.10+ 的 Annotated/X | None 语法)。该文件即仓库中的 tutorial004_an_py310.py

from datetime import datetime, timedelta, timezone
from typing import Annotated

import jwt
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jwt.exceptions import InvalidTokenError
from pwdlib import PasswordHash
from pydantic import BaseModel

# to get a string like this run:
# openssl rand -hex 32
SECRET_KEY = "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30


fake_users_db = {
    "johndoe": {
        "username": "johndoe",
        "full_name": "John Doe",
        "email": "johndoe@example.com",
        "hashed_password": "$argon2id$v=19$m=65536,t=3,p=4$wagCPXjifgvUFBzq4hqe3w$CYaIb8sB+wtD+Vu/P4uod1+Qof8h+1g7bbDlBID48Rc",
        "disabled": False,
    }
}


class Token(BaseModel):
    access_token: str
    token_type: str


class TokenData(BaseModel):
    username: str | None = None


class User(BaseModel):
    username: str
    email: str | None = None
    full_name: str | None = None
    disabled: bool | None = None


class UserInDB(User):
    hashed_password: str


password_hash = PasswordHash.recommended()

DUMMY_HASH = password_hash.hash("dummypassword")

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

app = FastAPI()


def verify_password(plain_password, hashed_password):
    return password_hash.verify(plain_password, hashed_password)


def get_password_hash(password):
    return password_hash.hash(password)


def get_user(db, username: str):
    if username in db:
        user_dict = db[username]
        return UserInDB(**user_dict)


def authenticate_user(fake_db, username: str, password: str):
    user = get_user(fake_db, username)
    if not user:
        verify_password(password, DUMMY_HASH)
        return False
    if not verify_password(password, user.hashed_password):
        return False
    return user


def create_access_token(data: dict, expires_delta: timedelta | None = None):
    to_encode = data.copy()
    if expires_delta:
        expire = datetime.now(timezone.utc) + expires_delta
    else:
        expire = datetime.now(timezone.utc) + timedelta(minutes=15)
    to_encode.update({"exp": expire})
    encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
    return encoded_jwt


async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        username = payload.get("sub")
        if username is None:
            raise credentials_exception
        token_data = TokenData(username=username)
    except InvalidTokenError:
        raise credentials_exception
    user = get_user(fake_users_db, username=token_data.username)
    if user is None:
        raise credentials_exception
    return user


async def get_current_active_user(
    current_user: Annotated[User, Depends(get_current_user)],
):
    if current_user.disabled:
        raise HTTPException(status_code=400, detail="Inactive user")
    return current_user


@app.post("/token")
async def login_for_access_token(
    form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
) -> Token:
    user = authenticate_user(fake_users_db, form_data.username, form_data.password)
    if not user:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password",
            headers={"WWW-Authenticate": "Bearer"},
        )
    access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    access_token = create_access_token(
        data={"sub": user.username}, expires_delta=access_token_expires
    )
    return Token(access_token=access_token, token_type="bearer")


@app.get("/users/me/")
async def read_users_me(
    current_user: Annotated[User, Depends(get_current_active_user)],
) -> User:
    return current_user


@app.get("/users/me/items/")
async def read_own_items(
    current_user: Annotated[User, Depends(get_current_active_user)],
):
    return [{"item_id": "Foo", "owner": current_user.username}]

使用提示:示例中的 SECRET_KEY 仅供演示,请务必执行 openssl rand -hex 32 自行生成;fake_users_db 实际使用时替换为真实数据库查询即可——整段安全逻辑与数据层完全解耦。


11. JWT 的 sub(subject)字段技术细节

JWT 规范定义了一个名为 sub 的声明,表示“令牌主体(subject)”。它是可选的,但正是存放用户标识的标准位置,因此本示例把用户名放进 sub

sub 并不局限于标识“用户”:

  • JWT 也可用于标识一辆“汽车”、一篇“博客文章”等实体;
  • 你可以在载荷中附加针对该实体的权限声明,如 drive(驾驶汽车)、edit(编辑博客);
  • 然后把令牌交给某个用户或机器人,让对方无需注册账号、仅凭你签发的 JWT 即可对相应资源执行操作。

这样 JWT 就能支撑远比“登录认证”复杂的授权场景。而在多实体场景中,不同实体可能拥有相同 ID(比如用户 foo、汽车 foo、博客 foo 都存在)。为避免 ID 冲突,签发用户令牌时可为 sub 加前缀命名空间,例如 username:johndoe——sub 的取值在本示例中也可以写成 username:johndoe

两个必须牢记的硬性约束:

  1. sub 在整个应用范围内应当是唯一的标识符
  2. sub 的值必须是字符串

这也解释了为什么 get_current_user 里要检查 payload.get("sub") is None 并拒绝缺少 sub 的令牌(任何不携带主体标识的令牌都应视为无效)。


12. 运行验证:在交互式文档中走一遍完整流程

启动应用后打开交互式文档地址 http://127.0.0.1:8000/docs,你会看到包含 /token/users/me//users/me/items/ 三个端点的 API 页面,右上角有绿色的 Authorize 按钮。

点击 Authorize 打开授权弹窗,输入本教程演示账号的凭据:

  • Username:johndoe
  • Password:secret

FastAPI 交互式文档的 OAuth2 授权弹窗,输入 johndoe/secret 获取令牌

授权完成后,调用 GET /users/me/,由于当前用户已通过 JWT 认证且未被禁用,接口返回该用户资料:

{
  "username": "johndoe",
  "email": "johndoe@example.com",
  "full_name": "John Doe",
  "disabled": false
}

Swagger UI 中调用 GET /users/me/ 返回用户资料,状态码 200

注意:上面的返回结果来自 get_current_user 经由 Pydantic 模型返回的用户数据——它不包含 hashed_password 字段,因为返回类型被声明为 User 而非 UserInDBUserInDB 才是携带哈希字段的内部模型,仅用于查库环节,FastAPI 会根据响应模型自动过滤多余字段)。

如果想观察请求细节,可以打开浏览器开发者工具(Network 标签)再调用一次 /users/me/,你会看到后续所有请求只携带令牌,密码仅在第一次换取 token 的请求中出现,此后不再传输:

Chrome 开发者工具 Network 面板中 /users/me/ 请求的请求头,Authorization 值为 Bearer + JWT 令牌

注意请求头 Authorization 的值以 Bearer 开头——这正是 first-steps.mdOAuth2PasswordBearer 所声明并注入到 OpenAPI 安全方案的 HTTP Bearer 认证形态。若令牌缺失或非法,接口会返回 401,并附带 WWW-Authenticate: Bearer 响应头。


13. 用仓库测试验证各安全分支

本仓库为该教程维护了一套覆盖完整行为矩阵的自动化测试:test_tutorial004.py。它通过 TestClient 直接驱动 tutorial004_an_py310.py / tutorial004_py310.py(fixture 参数化两条代码变体),你可以对照着验证自己对本节安全模型的理解:

测试函数 验证的安全分支 预期结果
test_login 正确凭据提交 /token 200,返回含 access_tokentoken_type == "bearer"
test_login_incorrect_password / test_login_incorrect_username 密码错 / 用户名不存在 均 401,文案统一为 "Incorrect username or password"
test_no_token 完全不携带令牌访问 /users/me 401 "Not authenticated" + WWW-Authenticate: Bearer
test_token 携带合法 Bearer 令牌 200,返回 johndoe 完整资料
test_incorrect_token 携带伪造令牌 "Bearer nonexistent" 401 "Could not validate credentials"
test_incorrect_token_type 携带非 Bearer 前缀的令牌 401 "Not authenticated"(OAuth2PasswordBearer 层拒绝)
test_token_no_sub / test_token_no_username / test_token_nonexistent_user 令牌缺 sub / sub 无对应用户 均 401 "Could not validate credentials"
test_token_inactive_user 被禁用用户(patch 注入 alice)登录后访问 400 "Inactive user"
test_verify_password / test_get_password_hash / test_create_access_token 工具函数单元行为 哈希可验证、函数可正常生成令牌
test_openapi_schema 导出的 OpenAPI 结构 snapshot 精确断言:OAuth2PasswordBearer 被声明为 type: oauth2flows.password.tokenUrl: "token"/users/me//users/me/items/ 的安全要求均为 OAuth2PasswordBearer

其中 test_openapi_schema 的 snapshot 恰好印证了第 1 节的链路:FastAPI 之所以知道要把 OAuth2 流写进 OpenAPI,正是因为 OAuth2PasswordBearer 是一个被识别为安全方案的依赖(而 OAuth2PasswordRequestForm 只是普通类依赖,只会以表单 schema 形式出现在 /token 的请求体描述中,见 snapshot 中 Body_login_for_access_token_token_postusername/password/grant_type/scope/client_id/client_secret 字段)。若你想查看本地生成的完整结构,运行应用后请求 http://127.0.0.1:8000/openapi.json 即可。


14. 进阶:scopes 与细粒度权限

OAuth2 规范还有 “scopes” 概念,可用于给 JWT 令牌附加一组特定的权限集合,再把这个受限令牌直接交给用户或第三方,让对方在约定的限制范围内操作你的 API。

在本教程基础上,FastAPI 的高级用户指南(Advanced User Guide)会进一步讲解 scopes 的用法及其与 FastAPI 的集成方式。这是 Facebook、Google、GitHub、Microsoft、X(Twitter)等大型认证提供方用来授权第三方应用代表用户访问其 API的标准机制。在掌握本节“密码流 + JWT”后,可继续沿着同一标准体系向 scopes 推进。OAuth2PasswordRequestForm 中可选的 scope 表单字段(空格分隔的字符串,依赖实例中对应 scopes 列表属性)正是为此预留的入口。


15. 总结

至此,你已经拥有搭建一套安全 FastAPI 应用的完整工具箱:

  • 密码单向哈希存储:用 pwdlib[argon2](推荐算法 Argon2)加盐哈希,配合假哈希比对抵御计时攻击,并支持跨框架(Django/Flask)口令数据互认与渐进迁移;
  • JWT 无状态认证:用 PyJWT 以 HS256 签名签发带 exp 过期时间的令牌,任何篡改都会被签名校验识破,令牌只在首次登录换取时涉及密码;
  • 依赖注入式的统一安全层get_current_user / get_current_active_user 把校验逻辑收敛到一次定义,数千个端点即可用三行代码复用同一套安全体系;
  • 标准协议兼容/token 端点、WWW-Authenticate: Beareraccess_token + token_type 响应、OpenAPI 安全方案声明均遵循 OAuth2 与 HTTP Bearer 规范。

不同框架的安全实现往往很快变得复杂,许多“一键简化”的包不得不在数据模型、数据库与功能上做出妥协,甚至隐含安全缺陷。而 FastAPI 不对任何数据库、数据模型或工具做假设——它把选择权完全交给你,并让你直接使用 pwdlibPyJWT 这类成熟且广泛维护的库,因为集成外部包不需要任何复杂机制;同时它又提供了尽可能简化流程的工具,在不牺牲灵活性、健壮性与安全性的前提下,让 OAuth2 这类标准协议可以用相对简单的方式落地。

关键参考文件

登录后查看全文
热门项目推荐
相关项目推荐