一次登录请求应先验证用户提交的密码,再签发有期限的访问令牌。后续请求携带令牌,服务器验证签名和声明,找回对应用户,再决定是否允许访问。FastAPI 官方这篇教程把这一流程连接起来,所用组件是 pwdlib、PyJWT 和 FastAPI 的安全依赖。
本文完整编译源页的概念与操作流程,把反复展示的同一程序合并为一个 Python 3.10+ 的 Annotated 版本,并明确标注安全改编。代码只经静态审查,没有启动服务或发送请求。内存用户、示例账户和演示资源均用于学习,不能视作完整身份平台。

一、JWT 是可验证的声明,不是密文
JWT 是 JSON Web Token。原文展示的是带签名的紧凑令牌:JSON 信息被编码为一段无空格字符串,接收方可以检查签名是否与预期密钥和算法匹配。这里的令牌没有加密,拿到它的人可以读取其中内容,因此不要放入密码、密钥或本不应向持有者公开的信息。
例如,签发时写入过期时间,用户次日带着令牌回来,服务器仍可据此识别身份;超过期限后应重新认证。若持有者修改过期时间,原有签名便不再匹配。原文以一周举例解释机制,实际程序将访问令牌设为 30 分钟;这只是示例策略,不是推荐所有业务采用的固定期限。
签名验证证明令牌符合持钥方的签发规则,不保证持有者就是最初登录的人。Bearer 令牌一旦泄露,别人也可能在有效期内使用它。生产环境还需 HTTPS、令牌存储策略、撤销或会话失效机制以及密钥轮换。
二、安装生成令牌和校验密码的依赖
在已经建立的 FastAPI 项目中,源页用 uv 添加两项依赖:
uv add pyjwt
uv add "pwdlib[argon2]"
若使用 RSA 或 ECDSA 这样的签名算法,原文提示安装带密码学依赖的 pyjwt[crypto]。本文代码沿用 HS256,因此生成与验证使用同一个秘密值。OAuth2 表单依赖仍需前文的 FastAPI 与表单解析安装条件;以上两条命令并不是从空目录建好全部应用的安装脚本。依赖版本应在项目中锁定,本稿不宣称已安装或测试某个版本组合。
三、数据库只保存密码哈希
密码哈希把密码变成用于校验的结果,登录时将用户输入与保存的哈希进行验证,而不是取出明文密码比对。数据库泄露时,这能避免直接暴露所有用户密码;但攻击者仍可能进行离线猜测,因此密码强度、哈希参数和泄露响应仍然重要。
编者修正:原文把哈希概括为“相同输入得到相同结果”,这适用于固定输入和参数的哈希运算,不能直接用来描述这里的带随机盐密码哈希。PasswordHash.recommended().hash() 对同一密码可以生成不同编码结果;盐和参数包含在编码结果中,verify() 才是正确校验方式,不应拿两次新生成的字符串直接比较。
源页推荐 Argon2,创建 PasswordHash.recommended() 后分别封装“生成哈希”和“校验哈希”函数。pwdlib 也支持 bcrypt;兼容其他系统时必须核对其具体算法和参数,不能假定它能读取所有 Django 或 Flask 插件的历史格式。原文对过时算法提出使用其他兼容库的思路,迁移时仍应把新密码写成当前选定的安全格式。
用户不存在时,示例仍对一个虚拟哈希执行一次验证。这样可减小“存在用户”和“不存在用户”之间的明显时间差,降低用户名枚举机会;它不是所有时间侧信道都已消除的证明。接口还应采用统一错误信息与限流。
四、完整代码与本稿的改动
原文提供一个公开的 SECRET_KEY,并明确要求换成自己生成的随机值,例如用 openssl rand -hex 32。该命令生成秘密,不要将输出贴入日志或版本库。本稿改为从 JWT_SECRET_KEY 环境变量读取;缺失时启动直接失败。这里不打印密钥,也没有执行生成命令。
下列完整示例还做了三项改编:解码时要求令牌包含 exp 和 sub;显式要求 sub 是非空字符串;禁用用户在签发阶段就被拒绝。密码验证所在的登录路由改为同步 def,使 FastAPI 可按同步路由机制处理这段 CPU 工作,避免原文 async def 内直接运行密码哈希验证;这仍不代替资源限额与压力测试。
# Python 3.10+;基于 FastAPI 官方教程,安全改编日期:2026-10-05。
import os
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
# 由部署配置注入独立生成的高熵密钥,禁止使用源文公开示例密钥。
SECRET_KEY = os.environ["JWT_SECRET_KEY"]
if not SECRET_KEY:
raise RuntimeError("JWT_SECRET_KEY must not be empty")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
# 仅用于演示。该账户密码为公开的 secret,不得部署到生产。
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 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:
return UserInDB(**db[username])
return None
def authenticate_user(db, username: str, password: str):
user = get_user(db, username)
if user is None:
verify_password(password, DUMMY_HASH)
return None
if not verify_password(password, user.hashed_password):
return None
return user
def create_access_token(data: dict, expires_delta: timedelta | None = None):
to_encode = data.copy()
duration = expires_delta if expires_delta is not None else timedelta(minutes=15)
to_encode["exp"] = datetime.now(timezone.utc) + duration
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
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],
options={"require": ["exp", "sub"]},
)
username = payload.get("sub")
if not isinstance(username, str) or not username:
raise credentials_exception
except InvalidTokenError:
raise credentials_exception
user = get_user(fake_users_db, 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")
def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
) -> Token:
user = authenticate_user(fake_users_db, form_data.username, form_data.password)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
if user.disabled:
raise HTTPException(status_code=400, detail="Inactive user")
access_token = create_access_token(
data={"sub": user.username},
expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
)
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}]
这一版省去了原文仅用于包装用户名的 TokenData 模型,改用显式的字符串检查;其余响应模型仍区分 User 和带哈希的 UserInDB。/users/me/ 的返回类型是 User,不把密码哈希作为公开字段输出。
五、沿着依赖链理解每一步
OAuth2PasswordBearer(tokenUrl="token") 告诉文档授权入口的位置,并从请求中获取 Bearer 令牌;它本身不负责验证 JWT。get_current_user() 才会调用 jwt.decode(),固定允许的算法列表为 ["HS256"],验证签名和有效期,然后用 sub 查询用户。不能从不可信令牌头里直接选择允许算法。
令牌无效、缺少所需声明或用户已不存在时,返回 401,并带 WWW-Authenticate: Bearer。下一层 get_current_active_user() 拒绝禁用用户;本文沿用原文在这一分支使用的 400 状态码。真正的业务接口只依赖这层结果,不必每个端点都重复写认证逻辑。
/token 接收 OAuth2 形式的用户名、密码表单,认证成功后调用签发函数。辅助函数没有收到时长时使用 15 分钟;此端点显式传入 30 分钟,因此实际登录签发使用后者。时间采用带时区的 UTC datetime,避免无时区时间的解释歧义。
另一个明确的改编差异:源文用 if expires_delta 判断时长,传入 timedelta(0) 时会回退到 15 分钟;本稿使用 is not None,零时长会令令牌立即到期。当前登录端点传入 30 分钟,不受这一区别影响。
静态审查边界:环境变量只把秘密移出代码,并不保证秘密有足够随机性,也不解决泄露、轮换或多服务隔离。若一个密钥被多个系统共享,仅验证签名可能接受其他系统签发的令牌;此时还应按架构配置并校验发行者、受众等声明。本示例未实现这些配置,也没有撤销列表、刷新令牌、速率限制或数据库事务。
六、sub 应在应用中唯一,并且是字符串
JWT 的 sub 表示令牌主体。它可以是用户,也可以是汽车、文章等其他实体。原文举例说明:令牌可以授予某人“驾驶这辆车”或“编辑这篇文章”的能力,并不一定只能用于登录账户。
如果用户、汽车和文章都可能有同一个局部编号 foo,便需要定义主体命名空间,例如 username:johndoe。重点是保证标识在整个应用范围内唯一,并用字符串表示。本文的内存示例只存在用户一种主体,所以仍沿用用户名;真实系统若允许改名,宜另行设计稳定标识。
七、如何按原文检查这条链路
以下是原文提供的人工核验步骤,不是本稿的测试记录。在隔离学习环境配置好依赖与独立密钥、启动应用后,访问 http://127.0.0.1:8000/docs,使用示例账户 johndoe 和密码 secret 授权,再调用 /users/me/。预期返回用户名、邮箱、全名与禁用状态,不返回哈希。
{
"username": "johndoe",
"email": "johndoe@example.com",
"full_name": "John Doe",
"disabled": false
}
浏览器网络面板应能看到后续受保护请求使用 Authorization: Bearer …,密码只在登录请求中提交。原文附有交互文档截图,本稿以原创流程图说明机制,没有把未经运行的结果伪装成截图。
本稿新增的验证项应包括:过期令牌、篡改令牌、缺少 exp 或 sub、非字符串主体、删除用户、禁用用户,以及用户响应不泄露哈希。上述项目均未进行执行验证,不应据此声称全部通过。
八、权限 scopes 与当前 OAuth 边界
OAuth2 的 scopes 用于表达令牌允许的权限范围。令牌可以交给用户或第三方,让其在有限权限内调用 API。原文把具体的 scopes 实现留给高级教程;本文也没有把“已登录”写成“已获所有资源权限”。每个业务操作仍要检查资源归属与所需权限,示例中的固定 Foo 只是演示返回值。
时效补充:OAuth 2.0 Security Best Current Practice(RFC 9700 §2.4)规定不得使用 Resource Owner Password Credentials grant。本文保留源教程中的密码表单与 JWT 教学结构,用于理解机制;新建面向第三方的 OAuth 登录,应依据当前身份架构采用授权码与 PKCE 等合适流程,不应将这篇旧式密码流示例直接当成新的 OAuth 方案。JWT 只是令牌格式,不能替代协议选择。
FastAPI 的价值在于允许项目选择自己的数据模型、数据库和成熟密码学库,并用依赖系统组织安全流程。最终安全性仍取决于密钥、声明校验、权限规则、传输与运维措施。
来源、署名与许可
原文:FastAPI — OAuth2 with Password (and hashing), Bearer with JWT tokens。作者:FastAPI 文档团队;项目版权 © 2018 Sebastián Ramírez,MIT License。许可证全文随稿保存在来源目录。未完纪于 2026-10-05 中文编译、合并重复示例并作上述明确标注的修改。
本稿依据源站全文及当前协议补充完成静态检查;未执行代码、未测时序或抗攻击能力,未发现的问题不代表不存在漏洞。
保留的完整许可声明
以下为原项目适用许可文本,原作者与文档归属及本文改动说明见正文。
The MIT License (MIT) Copyright (c) 2018 Sebastián Ramírez Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.












暂无评论内容