FastAPI 安全认证:JWT Token 令牌加密实战

FastAPI 安全认证:JWT Token 令牌加密实战


推荐阅读 📚🎓🧑‍🏫

[1] 一起学Python 专栏:深入探讨 Python 编程,涵盖基础与进阶内容,以及 NumPyPandasMatplotlibDockerLinux 等实用技术。


本文展示了如何在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 由三部分组成:

  1. Header(头部)
    • 通常包含两部分信息:令牌的类型(JWT)和所使用的签名算法(如 HMAC SHA256 或 RSA)。
    • 示例:{ "alg": "HS256", "typ": "JWT" }
  2. Payload(负载)
    • 这是 JWT 中存放的实际数据部分。它包含了声明(Claims),声明是 JWT 中传递的信息,比如用户 ID 或角色等。声明分为三类:
      • 注册声明(Registered Claims):这些是预定义的声明,例如 iss(发行者)、exp(过期时间)、sub(主题)等。
      • 公共声明(Public Claims):可以自定义的声明,必须避免冲突,通常用于传递自定义数据。
      • 私有声明(Private Claims):由双方约定的声明,用于传递双方之间共享的数据。
  3. Signature(签名)
    • 签名部分是为了验证数据在传输过程中没有被篡改。它是由头部和负载部分经过编码后,使用指定的算法(如 HMAC SHA256)和密钥(或私钥)生成的。
    • 示例:HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
2 JWT 的工作流程
用户 客户端 服务器 提交登录请求 返回 JWT 将 JWT 存储到 localStorage 或 sessionStorage 每次请求将 JWT 放在请求头中 (Authorization: Bearer <token>) 验证 JWT 签名有效性(密钥或公钥验证) 根据负载信息执行相应操作 用户 客户端 服务器
  1. 用户登录后,服务器生成一个 JWT 并返回给客户端。
  2. 客户端存储该 JWT(通常放在浏览器的 localStorage 或 sessionStorage 中),每次发起请求时,将 JWT 放在请求头(Authorization: Bearer )中发送给服务器。
  3. 服务器接收到 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}]


九 源码地址

详情见:GitHub FastApiProj

十 参考

[1] FastAPI 文档

[2] PyJWT 文档

[3] JWT IO

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

敲代码不忘补水

感谢有你,让我的创作更有价值!

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值