FastAPI 安全认证:JWT Token 令牌加密实战
推荐阅读 📚🎓🧑🏫
[1] 一起学Python 专栏:深入探讨 Python 编程,涵盖基础与进阶内容,以及 NumPy、Pandas、Matplotlib、Docker、Linux 等实用技术。
本文展示了如何在FastAPI中实现OAuth2密码哈希和Bearer JWT令牌验证的功能。通过示例代码,学习如何使用JWT
生成和验证访问令牌,以及如何使用OAuth2
协议进行用户认证。此外,文章深入探讨了JWT的结构、工作流程及其优缺点,并提供了如何使用passlib
对密码进行哈希加密的实战示例。通过这篇文章,读者可以掌握如何为API实现安全的认证机制,确保用户信息的安全性和访问的可靠性。
文章目录
预备课: FastAPI 安全认证:OAuth2 实现简单的 Password 和登录令牌验证
以下示例中使用的 Python 版本为 Python 3.10.15
,FastAPI 版本为 0.115.4
。
一 示例代码
from datetime import datetime, timedelta, timezone
import jwt
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jwt.exceptions import InvalidTokenError
from passlib.context import CryptContext
from pydantic import BaseModel
# to get a string like this run:
# openssl rand -hex 32
SECRET_KEY = "dbf0111cf51ab8cd87f0a970e997e78f4007639b21f9cd8feeccdbbe0d498ea0"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
fake_users_db = {
"johndoe": {
"username": "johndoe",
"full_name": "John Doe",
"email": "johndoe@example.com",
"hashed_password": "$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW",
"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
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
app = FastAPI()
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password):
return pwd_context.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:
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: 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: str = 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: 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: 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/", response_model=User)
async def read_users_me(current_user: User = Depends(get_current_active_user)):
return current_user
@app.get("/users/me/items/")
async def read_own_items(current_user: User = Depends(get_current_active_user)):
return [{"item_id": "Foo", "owner": current_user.username}]
运行代码文件 an04.py 来启动应用:
$ uvicorn an04:app --reload
在 SwaggerUI 中可以查看在线文档:http://127.0.0.1:8000/docs
。
注:在 Python 中生成和校验 JWT 令牌,需要安装 pyjwt
,安装命令如下:
$ pip install pyjwt
# 使用 RSA 或 ECDSA 等数字签名算法
$ pip install "pyjwt[crypto]"
二 JWT 简介
JWT(JSON Web Token)是用于安全传递信息的开放标准,广泛应用于身份验证和信息交换。它紧凑、URL 安全、跨平台,适用于 Web 和移动端。使用时需注意签名密钥保护、过期时间和权限控制。
1 JWT 的结构
JWT 由三部分组成:
- Header(头部):
- 通常包含两部分信息:令牌的类型(JWT)和所使用的签名算法(如 HMAC SHA256 或 RSA)。
- 示例:
{ "alg": "HS256", "typ": "JWT" }
- Payload(负载):
- 这是 JWT 中存放的实际数据部分。它包含了声明(Claims),声明是 JWT 中传递的信息,比如用户 ID 或角色等。声明分为三类:
- 注册声明(Registered Claims):这些是预定义的声明,例如
iss
(发行者)、exp
(过期时间)、sub
(主题)等。 - 公共声明(Public Claims):可以自定义的声明,必须避免冲突,通常用于传递自定义数据。
- 私有声明(Private Claims):由双方约定的声明,用于传递双方之间共享的数据。
- 注册声明(Registered Claims):这些是预定义的声明,例如
- 这是 JWT 中存放的实际数据部分。它包含了声明(Claims),声明是 JWT 中传递的信息,比如用户 ID 或角色等。声明分为三类:
- Signature(签名):
- 签名部分是为了验证数据在传输过程中没有被篡改。它是由头部和负载部分经过编码后,使用指定的算法(如 HMAC SHA256)和密钥(或私钥)生成的。
- 示例:
HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
2 JWT 的工作流程
- 用户登录后,服务器生成一个 JWT 并返回给客户端。
- 客户端存储该 JWT(通常放在浏览器的 localStorage 或 sessionStorage 中),每次发起请求时,将 JWT 放在请求头(Authorization: Bearer )中发送给服务器。
- 服务器接收到 JWT 后,通过密钥或公钥验证签名的有效性,并根据负载中的信息执行相应操作。
3 JWT 的优缺点
优点
- 无状态性:JWT 自包含所有信息,服务器无需保存会话状态,减少了对数据库的依赖,适合分布式系统和微服务架构。
- 跨平台支持:JWT 可以在不同的应用和语言之间传递,且被广泛支持。
- 灵活性:JWT 可以携带各种类型的声明,适应不同的使用场景。
缺点
- 不适合存储敏感信息:JWT 默认不加密,所以其负载部分的信息如果包含敏感数据(如密码),可能会泄露。
- 密钥管理:JWT 的安全性依赖于密钥的安全管理,如果密钥泄露,JWT 就会变得不安全。
- 没有撤销机制:JWT 一旦签发,无法撤销,直到它过期。如果用户权限变更或注销,JWT 可能会继续有效。
三 密码哈希与校验
1 安装 passlib
Passlib 是用于处理密码哈希的 Python 包,支持多种安全哈希算法。本教程推荐使用 Bcrypt 算法。
$ pip install "passlib[bcrypt]"
注: passlib
兼容 Django、Flask 等工具创建的密码,适用于跨框架数据共享或迁移。
2 密码校验
# 导入 passlib 库中的 CryptContext 用于密码哈希和验证
from passlib.context import CryptContext
# 创建一个 CryptContext 实例,指定使用的哈希算法为 bcrypt
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# 校验密码:比对传入的明文密码和存储的哈希密码是否匹配
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
# 获取密码哈希:对明文密码进行哈希处理并返回哈希值
def get_password_hash(password):
return pwd_context.hash(password)
# 用户身份验证:根据用户名和密码验证用户
def authenticate_user(fake_db, username: str, password: str):
user = get_user(fake_db, username) # 获取用户
if not user:
return False # 用户不存在,返回 False
if not verify_password(password, user.hashed_password): # 校验密码
return False # 密码错误,返回 False
return user # 用户身份验证成功,返回用户对象
四 处理 JWT 令牌
1 生成随机密钥
$ openssl rand -hex 32
dbf0111cf51ab8cd87f0a970e997e78f4007639b21f9cd8feeccdbbe0d498ea0
2 生成 JWT Token
import jwt
# 导入 FastAPI 中用于 OAuth2 认证的模块
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
# 定义密钥和加密算法,用于生成和验证 JWT
SECRET_KEY = "dbf0111cf51ab8cd87f0a970e997e78f4007639b21f9cd8feeccdbbe0d498ea0"
ALGORITHM = "HS256"
# 定义 Token 模型,包含访问令牌和令牌类型
class Token(BaseModel):
access_token: str
token_type: str
# 创建访问令牌函数:根据传入的数据生成 JWT
def create_access_token(data: dict, expires_delta: timedelta | None = None):
# 复制数据以避免修改原始数据
to_encode = data.copy()
# 如果指定了过期时间,则使用指定时间;否则默认 15 分钟
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
# 更新数据字典,加入过期时间(exp)
to_encode.update({"exp": expire})
# 使用密钥和算法对数据进行编码,生成 JWT
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
# 返回生成的 JWT 令牌
return encoded_jwt
五 解码并校验接令牌
# 定义异步函数 get_current_user,用于获取当前用户
# 使用 Depends(oauth2_scheme) 从请求中提取并验证 JWT 令牌
async def get_current_user(token: str = Depends(oauth2_scheme)):
# 定义认证失败时抛出的异常,401 状态码
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
# 解码 JWT,验证签名,提取负载信息
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
# 从负载中获取用户名(sub 为主题字段)
username: str = payload.get("sub")
# 如果没有找到用户名,则抛出认证异常
if username is None:
raise credentials_exception
# 使用 TokenData 模型将用户名存入 token_data
token_data = TokenData(username=username)
except InvalidTokenError:
# 如果解码失败或无效的 JWT,抛出认证异常
raise credentials_exception
# 根据解码后的用户名获取用户信息
user = get_user(fake_users_db, username=token_data.username)
# 如果用户不存在,抛出认证异常
if user is None:
raise credentials_exception
# 返回当前用户
return user
六 token 令牌应用
# 定义一个 POST 请求的路由,用于用户登录并获取访问令牌
@app.post("/token")
async def login_for_access_token(
form_data: OAuth2PasswordRequestForm = Depends(), # 从请求中获取表单数据(用户名和密码)
) -> Token:
# 使用 authenticate_user 函数验证用户身份
user = authenticate_user(fake_users_db, form_data.username, form_data.password)
# 如果用户身份验证失败,抛出 HTTP 401 未授权异常
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, # 状态码 401 表示未授权
detail="Incorrect username or password", # 错误信息
headers={"WWW-Authenticate": "Bearer"}, # 添加 Bearer 认证类型
)
# 设置访问令牌的过期时间
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
# 调用 create_access_token 函数生成访问令牌,并设置有效期
access_token = create_access_token(
data={"sub": user.username}, # 数据部分,包含用户名(sub 字段)
expires_delta=access_token_expires # 设置令牌过期时间
)
# 返回生成的 Token,包含 access_token 和 token_type
return Token(access_token=access_token, token_type="bearer")
建议:sub
键在整个应用中应该只有一个唯一的标识符,且类型应为字符串。
七 实战
首次请求发送密码获取令牌,之后的请求需包含令牌,如请求 /users/me
。
curl -X 'GET' \
'http://127.0.0.1:8000/users/me/' \
-H 'accept: application/json' \
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJqb2huZG9lIiwiZXhwIjoxNzM5MzMzMTc5fQ.TQXiTE63ryGvdh13JZHD2I8UDBN3OXFtlvsmfwz1l0g'
响应返回:
{
"username": "johndoe",
"email": "johndoe@example.com",
"full_name": "John Doe",
"disabled": false
}
八 完整代码示例
from datetime import datetime, timedelta, timezone
import jwt
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jwt.exceptions import InvalidTokenError
from passlib.context import CryptContext
from pydantic import BaseModel
# to get a string like this run:
# openssl rand -hex 32
SECRET_KEY = "dbf0111cf51ab8cd87f0a970e997e78f4007639b21f9cd8feeccdbbe0d498ea0"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
fake_users_db = {
"johndoe": {
"username": "johndoe",
"full_name": "John Doe",
"email": "johndoe@example.com",
"hashed_password": "$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW",
"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
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
app = FastAPI()
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password):
return pwd_context.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:
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: 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: str = 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: 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: 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/", response_model=User)
async def read_users_me(current_user: User = Depends(get_current_active_user)):
return current_user
@app.get("/users/me/items/")
async def read_own_items(current_user: User = Depends(get_current_active_user)):
return [{"item_id": "Foo", "owner": current_user.username}]
九 源码地址
十 参考
[1] FastAPI 文档
[2] PyJWT 文档
[3] JWT IO