如何在FastAPI中添加JWT认证--实用指南

4,458 阅读9分钟

FastAPI是一个用Python编写的现代的、快速的、经过战斗检验的、轻量级的web开发框架。这个领域的其他流行选择是DjangoFlaskBottle

由于它是新的,FastAPI既有优点也有缺点。

在积极的一面,FastAPI实现了所有的现代标准,充分利用了最新Python版本所支持的功能。它有异步支持和类型提示。而且它还很快速(因此被称为FastAPI),不受影响,健壮,易于使用。

在消极方面,FastAPI缺少一些复杂的功能,比如Django中的开箱即用的用户管理和管理面板。FastAPI的社区支持很好,但不如其他框架,这些框架已经存在多年,并且有数百甚至数千个针对不同用例的开源项目。

这是对FastAPI的一个非常简单的介绍。在这篇文章中,你将通过一个实际例子了解如何在FastAPI中实现JWT(JSON Web Token)认证。

项目设置

在这个例子中,我将使用 复制(一个伟大的基于网络的IDE)。另外,你可以简单地按照文档在本地设置你的FastAPI项目,或者通过分叉使用这个复制的启动模板。这个模板已经安装了所有需要的依赖项。

如果你在本地环境中设置了项目,下面是你需要安装的JWT认证的依赖项(假设你有一个FastAPI项目在运行)。

pip install "python-jose[cryptography]" "passlib[bcrypt]" python-multipart

注意: 为了存储用户,我将使用replit的内置数据库。但如果你使用任何标准的数据库,如PostgreSQL、MongoDB等,你也可以应用类似的操作。

如果你想看完整的实现,我有这个完整的视频教程,包括一个生产就绪的FastAPI应用程序可能拥有的一切。

带有JWT认证的FastAPI应用程序

带有FastAPI的认证

一般来说,认证可能有很多活动部件,从处理密码散列和分配令牌到在每个请求上验证令牌。

FastAPI利用依赖性注入(一种软件工程设计模式)来处理认证方案。下面是这个过程中的一些一般步骤的列表。

  • 密码散列
  • 创建和分配JWT令牌
  • 创建用户
  • 在每个请求上验证令牌以确保认证

密码散列

当用用户名和密码创建用户时,你需要在将密码存储到数据库之前对其进行散列。让我们看看如何轻松地哈希密码。

app 目录中创建一个名为utils.py 的文件,并添加以下函数来散列用户密码。

from passlib.context import CryptContext

password_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


def get_hashed_password(password: str) -> str:
    return password_context.hash(password)


def verify_password(password: str, hashed_pass: str) -> bool:
    return password_context.verify(password, hashed_pass)

我们正在使用passlib 来创建密码散列的配置环境。这里我们将其配置为使用bcrypt

get_hashed_password 函数接收一个普通密码,并返回可以安全存储在数据库中的哈希值。verify_password 函数接收普通密码和散列密码,并返回一个布尔值,代表密码是否匹配。

如何生成JWT令牌

在本节中,我们将编写两个辅助函数来生成具有特定有效载荷的访问和刷新令牌。之后我们可以使用这些函数,通过传递与用户相关的有效载荷,为特定的用户生成令牌。

在你之前创建的app/utils.py 文件中,添加以下导入语句。

import os
from datetime import datetime, timedelta
from typing import Union, Any
from jose import jwt

用于创建访问和刷新令牌的导入

添加以下常量,这些常量将在创建JWTs时传递。

ACCESS_TOKEN_EXPIRE_MINUTES = 30  # 30 minutes
REFRESH_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
ALGORITHM = "HS256"
JWT_SECRET_KEY = os.environ['JWT_SECRET_KEY']   # should be kept secret
JWT_REFRESH_SECRET_KEY = os.environ['JWT_REFRESH_SECRET_KEY']    # should be kept secret

用于创建访问和刷新令牌的常量

JWT_SECRET_KEY 和 ,可以是任何字符串,但要确保对它们保密,并将它们设置为环境变量。JWT_REFRESH_SECRET_KEY

如果你在replit.com上关注,你可以在左边菜单栏的Secrets 标签中设置这些环境变量。

app/utils.py 文件的末尾添加以下函数。

def create_access_token(subject: Union[str, Any], expires_delta: int = None) -> str:
    if expires_delta is not None:
        expires_delta = datetime.utcnow() + expires_delta
    else:
        expires_delta = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    
    to_encode = {"exp": expires_delta, "sub": str(subject)}
    encoded_jwt = jwt.encode(to_encode, JWT_SECRET_KEY, ALGORITHM)
    return encoded_jwt

def create_refresh_token(subject: Union[str, Any], expires_delta: int = None) -> str:
    if expires_delta is not None:
        expires_delta = datetime.utcnow() + expires_delta
    else:
        expires_delta = datetime.utcnow() + timedelta(minutes=REFRESH_TOKEN_EXPIRE_MINUTES)
    
    to_encode = {"exp": expires_delta, "sub": str(subject)}
    encoded_jwt = jwt.encode(to_encode, JWT_REFRESH_SECRET_KEY, ALGORITHM)
    return encoded_jwt

生成访问令牌和刷新令牌的函数

这两个函数的唯一区别是,刷新令牌的过期时间比访问令牌的过期时间要长。

这些函数只是接收要包含在JWT中的有效载荷,它可以是任何东西。通常,你想在这里存储像USER_ID这样的信息,但这可以是任何东西,从字符串到对象/字典。这些函数以字符串形式返回令牌。

最后,你的app/utils.py 文件应该看起来像这样。

from passlib.context import CryptContext
import os
from datetime import datetime, timedelta
from typing import Union, Any
from jose import jwt

ACCESS_TOKEN_EXPIRE_MINUTES = 30  # 30 minutes
REFRESH_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
ALGORITHM = "HS256"
JWT_SECRET_KEY = os.environ['JWT_SECRET_KEY']     # should be kept secret
JWT_REFRESH_SECRET_KEY = os.environ['JWT_REFRESH_SECRET_KEY']      # should be kept secret

password_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

def get_hashed_password(password: str) -> str:
    return password_context.hash(password)


def verify_password(password: str, hashed_pass: str) -> bool:
    return password_context.verify(password, hashed_pass)


def create_access_token(subject: Union[str, Any], expires_delta: int = None) -> str:
    if expires_delta is not None:
        expires_delta = datetime.utcnow() + expires_delta
    else:
        expires_delta = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    
    to_encode = {"exp": expires_delta, "sub": str(subject)}
    encoded_jwt = jwt.encode(to_encode, JWT_SECRET_KEY, ALGORITHM)
    return encoded_jwt

def create_refresh_token(subject: Union[str, Any], expires_delta: int = None) -> str:
    if expires_delta is not None:
        expires_delta = datetime.utcnow() + expires_delta
    else:
        expires_delta = datetime.utcnow() + timedelta(minutes=REFRESH_TOKEN_EXPIRE_MINUTES)
    
    to_encode = {"exp": expires_delta, "sub": str(subject)}
    encoded_jwt = jwt.encode(to_encode, JWT_REFRESH_SECRET_KEY, ALGORITHM)
    return encoded_jwt

如何处理用户的注册

app/app.py 文件中,创建另一个用于处理用户注册的端点。这个端点应该接受用户名/电子邮件和密码作为数据。然后它检查以确保另一个具有该电子邮件/用户名的账户不存在。然后,它创建用户并将其保存到数据库中。

app/app.py ,添加以下处理函数。

from fastapi import FastAPI, status, HTTPException
from fastapi.responses import RedirectResponse
from app.schemas import UserOut, UserAuth
from replit import db
from app.utils import get_hashed_password
from uuid import uuid4

@app.post('/signup', summary="Create new user", response_model=UserOut)
async def create_user(data: UserAuth):
    # querying database to check if user already exist
    user = db.get(data.email, None)
    if user is not None:
            raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="User with this email already exist"
        )
    user = {
        'email': data.email,
        'password': get_hashed_password(data.password),
        'id': str(uuid4())
    }
    db[data.email] = user    # saving user to database
    return user

如何处理登录

FastAPI有一个处理登录的标准方法,以符合OpenAPI标准。这在swagger docs中自动添加了认证,无需任何额外的配置。

为用户登录添加以下处理函数,并为每个用户分配访问和刷新令牌。不要忘记包括导入。

from fastapi import FastAPI, status, HTTPException, Depends
from fastapi.security import OAuth2PasswordRequestForm
from fastapi.responses import RedirectResponse
from app.schemas import UserOut, UserAuth, TokenSchema
from replit import db
from app.utils import (
    get_hashed_password,
    create_access_token,
    create_refresh_token,
    verify_password
)
from uuid import uuid4

@app.post('/login', summary="Create access and refresh tokens for user", response_model=TokenSchema)
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
    user = db.get(form_data.username, None)
    if user is None:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Incorrect email or password"
        )

    hashed_pass = user['password']
    if not verify_password(form_data.password, hashed_pass):
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Incorrect email or password"
        )
    
    return {
        "access_token": create_access_token(user['email']),
        "refresh_token": create_refresh_token(user['email']),
    }

这个端点与其他的post端点有点不同,在那里你定义了过滤传入数据的模式。

对于登录端点,我们使用OAuth2PasswordRequestForm 作为依赖关系。这将确保从请求中提取数据并作为form_data 参数传递给login 处理函数。python-multipart 用于提取表单数据。所以请确保你已经安装了它。

端点将反映在swagger文档中的用户名和密码的输入。

image-49

在成功的响应中,你会得到如图所示的令牌。

image-50

如何添加受保护的路由

现在,由于我们已经添加了对登录和注册的支持,我们可以添加受保护的端点。在FastAPI中,受保护的端点是使用依赖性注入来处理的,FastAPI可以从OpenAPI模式中推断出来,并反映在swagger文档中。

让我们看看依赖性注入的威力。在这一点上,我们没有办法从文档中进行认证。这是因为目前我们没有任何受保护的端点,所以OpenAPI模式没有足够的关于我们所使用的登录策略的信息。

image-51

在swagger文档中没有登录的按钮。

让我们来创建我们的自定义依赖。它只不过是一个在实际处理函数之前运行的函数,以获得传递给处理函数的参数。让我们通过一个实际的例子来看看。

创建另一个文件app/deps.py ,并在其中加入以下函数。

from typing import Union, Any
from datetime import datetime
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from .utils import (
    ALGORITHM,
    JWT_SECRET_KEY
)

from jose import jwt
from pydantic import ValidationError
from app.schemas import TokenPayload, SystemUser
from replit import db

reuseable_oauth = OAuth2PasswordBearer(
    tokenUrl="/login",
    scheme_name="JWT"
)


async def get_current_user(token: str = Depends(reuseable_oauth)) -> SystemUser:
    try:
        payload = jwt.decode(
            token, JWT_SECRET_KEY, algorithms=[ALGORITHM]
        )
        token_data = TokenPayload(**payload)
        
        if datetime.fromtimestamp(token_data.exp) < datetime.now():
            raise HTTPException(
                status_code = status.HTTP_401_UNAUTHORIZED,
                detail="Token expired",
                headers={"WWW-Authenticate": "Bearer"},
            )
    except(jwt.JWTError, ValidationError):
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Could not validate credentials",
            headers={"WWW-Authenticate": "Bearer"},
        )
        
    user: Union[dict[str, Any], None] = db.get(token_data.sub, None)
    
    
    if user is None:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="Could not find user",
        )
    
    return SystemUser(**user)

这里我们将get_current_user 函数定义为依赖关系,而这个依赖关系又将OAuth2PasswordBearer 的一个实例作为依赖关系。

reuseable_oauth = OAuth2PasswordBearer(
    tokenUrl="/login",
    scheme_name="JWT"
)

OAuth2PasswordBearer 需要两个必要的参数。 是你的应用程序中处理用户登录和返回令牌的URL。 设置为 将允许前端的swagger文档从前端调用 ,并在内存中保存令牌。然后,随后对受保护端点的每个请求都会将令牌作为 headers发送,以便tokenUrl scheme_name JWT tokenUrl Authorization `OAuth2PasswordBearer`可以解析它。

现在让我们添加一个受保护的端点,作为响应返回用户账户信息。为此,一个用户必须登录,端点将响应当前登录用户的信息。

app/app.py ,创建另一个处理函数。请确保也包括导入。

from app.deps import get_current_user

@app.get('/me', summary='Get details of currently logged in user', response_model=UserOut)
async def get_me(user: User = Depends(get_current_user)):
    return user

一旦你添加了这个端点,你就可以在swagger文档中看到Authorize 按钮,并且在受保护的端点前面有一个🔒图标/me

image-56

这是依赖性注入和FastAPI自动生成OpenAPI模式的能力。

点击Authorize 按钮将打开带有登录所需字段的授权表格。在一个成功的响应中,令牌将被保存,并在标题中被发送到后续请求中。

image-57

Swagger集成的登录表单

image-58

成功登录

在这一点上,你可以访问所有受保护的端点。要使一个端点受到保护,你只需要把get_current_user 函数作为一个依赖项。这就是你需要做的所有事情

总结

如果你跟上了,你应该有一个带JWT认证的FastAPI应用程序。如果没有,你可以随时运行这个副本并玩一玩,或者访问这个部署的版本。你可以在这里找到这个项目的GitHub代码。

如果你觉得这篇文章有帮助,请在twitter上关注我@abdadeel_。不要忘了,你可以随时观看这个视频,通过一个实际的例子了解详细的解释。

谢谢;)


Abdullah Adeel

Abdullah Adeel

一个自学成才的开发者,喜欢学习,然后分享学习成果。


如果你读到这里,请发推特给作者,表示你的关心。鸣谢

FreeCodeCamp的开源课程已经帮助超过40,000人获得了开发者的工作。开始吧