为了账号安全,请及时绑定邮箱和手机立即绑定

FastAPI中实现HTTP认证系统的实战指南

标签:
Python 安全 API

FastAPI的HTTP认证

由于 FastAPI 是作为 API 系统设计的,它并不包含处理 HTML 的相关功能,并且没有基于 HTML 页面会话认证的 Session 或认证系统。

我建立了一个这样的系统,在这个过程中学到了很多关于如何处理FastAPI的重要教训。所以这个系列不仅仅关于添加认证,还包括一些使用FastAPI的整体方法。

我的代码可以在这里找到,点击这里: here

基于建议的翻译结果:
一个更加自然的表达可以是 "# 所用的库" 或 "# 用到的库"。这里使用 "# 使用的库" 也完全正确且常用,考虑到目标受众对术语的熟悉度,保持原译即可。
使用的库

在我们开始之前,我想介绍一下除了FastAPI以外我还用到的一些Python库。

python-dotenv

如果你还没有将这个库或类似库加入到你的 web 应用程序工具箱中,那么你应该这样做。用户详情、连接详情、密钥等敏感信息,绝不能存放在代码仓库里。DotEnv 是处理这些信息的标准工具。

Tortoise ORM

我们需要一个用于用户管理的数据库。标准是SQLAlchemy,但我一直和它合不来。它总是不断增加新功能,虽然功能强大,几乎无所不能,但也意味着有很多做同一件事的方式,这可能会让人感到困惑。Tortoise ORM 是一个异步 ORM,灵感来源于Django ORM。熟悉 Django 的人会觉得 Tortoise 更容易上手,但它们之间还是有很多不同之处。它在处理很多事情时的方式有所不同,我认为,某些方面甚至做得比 Django 更好。

asyncpg

我在使用Postgresql数据库,因此需要一个异步驱动来连接它。还有其他选择,所以根据你的需求,将其改为基于Python的驱动。

bulma.io

这不是一个Python库,而是一个CSS库。我用这个CSS库来布局HTML页面,使其布局变得简单。此外,我也没有在该项目中使用任何模板库。FastAPI自带Jinja2响应对象,但使用其他模板语言也很简单,可以通过HTMLResponse类实现。

创建设置模块

我发现过如果我在根应用文件中读取 dotenv 文件,会遇到循环导入的问题,所以我决定把所有设置导入到一个单独的文件中,然后在需要的地方只需导入这个文件。

    from dotenv import find_dotenv, dotenv_values  
    import pathlib  
    import string  

    # 获取当前工作目录  
    file_path = pathlib.Path().cwd()  
    # 获取静态文件目录  
    static_dir = str(pathlib.Path(pathlib.Path().cwd(), "static"))  
    # 获取数据库配置信息  
    config = dotenv_values(find_dotenv(".test_fastapi_config.env"))  

    # 获取数据库用户名  
    db_user = config.get("DB_USER")  
    # 获取数据库密码  
    db_password = config.get("DB_PASSWORD")  
    # 获取数据库主机  
    db_host = config.get("DB_HOST")  
    # 获取数据库名称  
    db_name = config.get("DB_NAME")  
    # 获取数据库端口  
    db_port = config.get("DB_PORT")  
    # 构造数据库URL  
    db_url = f"asyncpg://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}"  
    # 数据库模块设置  
    db_modules = {"models": ["models"]}  

    # 设置最大缓存时间  
    max_age = 3600  

    # 设置会话选择字符  
    session_choices = string.ascii_letters + string.digits + "=+%$#"

你会注意到我更喜欢使用pathlib而不是os库。最好把所有多个文件都会用到的设置放在这个文件里,但这个文件里不要做FastAPI的任何设置。

数据库模型及配置

很明显,我创建了models.py文件,并在其中定义了数据库表。

from tortoise import fields, models  
from tortoise.contrib.pydantic import pydantic_model_creator  

def default_scope():  
    return ["authenticated"]  

class Users(models.Model):  
    """  
    用户模型  
    """  
    id = fields.IntField(primary_key=True)  
    username = fields.CharField(max_length=20, unique=True)  
    first_name = fields.CharField(max_length=50, null=True)  
    last_name = fields.CharField(max_length=50, null=True)  
    p_hash = fields.BinaryField(max_length=128, null=True)  
    p_salt = fields.BinaryField(max_length=128, null=True)  
    scope = fields.JSONField(default=default_scope)  # 权限范围  
    info = fields.JSONField(default=dict)  # 默认=dict  

    class PydanticMeta:  
        exclude = ["p_hash"]  # 排除p_hash  

User_Pydantic = pydantic_model_creator(Users, name="User")  
UserIn_Pydantic = pydantic_model_creator(Users, name="UserIn", exclude_readonly=True)  # 排除只读  

class Session(models.Model):  
    """  
    会话模型  
    """  
    id = fields.IntField(primary_key=True)  
    token = fields.CharField(max_length=128, unique=True, db_index=True)  
    user = fields.IntField(default=0)  
    created_at = fields.DatetimeField(auto_now_add=True)  
    expires_at = fields.DatetimeField(auto_now_add=True)  

Session_Pydantic = pydantic_model_creator(Session, name="Session")

关于这些模型,我们有几件事要讨论。

范畴

这是我在Starlette中间件中注意到的一个特性,每个用户都有一个权限列表,默认情况为“已认证”,你可以添加“管理员”、“超级用户”等权限,以在需要限制访问的视图中使用,比如只允许特定用户访问。

p_hash(哈希值),p_salt(盐值)

我研究了几种不同的密码存储方式,最终决定采用这一种。我使用了内置的hashlib库,并采用了pbkdf2_hmac方法,加密方式为“sha256”。密码是单向加密的,使用的是每个用户独有的盐值(salt)。为了验证密码,需要使用p_salt值对密码进行加密,然后与p_hash值进行匹配。我也考虑过使用服务器上保存的公共盐值,但似乎这种方法被认为更安全。如果您想使用其他方法,可以自由更换。盐值是一个128字节的随机字符串,保存在二进制字段中。

会话密钥

对于会话管理方面,我希望能够像 Django 那样,让会话 cookie 不存放任何用户信息。会话 cookie 分配一个随机生成的 128 个字符的字符串,用于与会话数据库中的记录匹配。会话记录仅由经过验证的用户创建。

给FastAPI加上ORM (让我们给FastAPI加上ORM吧)
让我们给FastAPI加上ORM吧 (ORM:对象关系映射)

ORM 的引入利用了 FastAPI 中的一个相对较新的功能,那就是 lifespan 对象。要理解这里的情况,你需要了解这个概念。Starlette 和 FastAPI 允许在 FastAPI/app 对象的 '启动' 和 '关闭' 事件上, 执行操作。过去这是通过不同的方法实现的。

@app.on_event("startup")  
async def my_event():  
  pass
# 当应用程序启动时触发的异步事件

这个功能在 FastAPI 中已废弃,将被 “lifespan” 替换。

使用生命周期,我们基本上将 FastAPI 对象包裹在一个 Python 上下文管理器中。这意味着我们会在主代码块执行之前运行这部分代码,并在代码块完成后运行另一部分代码。因此,它就像为主对象添加了一层中间件。

这确实需要是一个异步上下文管理器(asynccontextmanager),并且“yield”语句分别标记了代码执行前后的部分。

    from fastapi import FastAPI  
    import settings  
    from contextlib import asynccontextmanager  
    from typing import AsyncGenerator  
    from tortoise.contrib.fastapi import RegisterTortoise  

    @asynccontextmanager  
    async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:  
        # 应用启动 阶段
        async with RegisterTortoise(  
            app,  
            db_url=settings.db_url,  
            modules=settings.db_modules,  
            generate_schemas=True,  
            add_exception_handlers=True,  
        ):  
            # 连接数据库
            yield  
            # 应用关闭

    main_app = FastAPI(lifespan=lifespan)

请注意以下事项,我已经在设置文件中添加了数据库连接URL和这些模块。这些模块用于指定模型的位置。

另一个需要注意的点是,如果你使用了“生命周期”参数,那么任何“on_event()”方法调用将不会被处理。然而,AI助手仍然建议使用on_event,因为这符合编程习惯,尽管它不会起作用。

中间件技术

Starlette 有会话和认证的中间件,但它们似乎不起作用。FastAPI 的文档提到相关 Starlette 中间件,但实际使用时只能部分运行,并且可能会有意外结果。比如 ‘require()’ 装饰器根本无法正常使用。

然而,用FastAPI创建中间件并不难。我觉得花在调试Starlette中间件为何不起作用的时间比我写自己的中间件的时间还长。用FastAPI创建中间件非常简单。

处理会话的中间件

对于我的系统来说,唯一需要的就是在每次调用后重新发送之前创建的携带唯一标识符的 cookie,这个唯一标识符可以与后端会话表中的记录匹配。

    @main_app.middleware("http")  
    async def session_middleware(request: Request, call_next):  
        cookie_val = request.cookies.get("session")  
        if cookie_val:  
            request.scope['session'] = cookie_val  
        else:  
            request.scope['session'] = "".join(random.choices(settings.会话选项, k=128))  
        response = await call_next(request)  
        response.set_cookie("session", value=request.session,  
                            max_age=settings.最大有效期, httponly=True)  
        return response

这里有几个要点需要注意。会话令牌只是一个128字符的字符串而已。我限制了字符串中的值,以避免出现任何可能引起问题的标点符号,这样不会遇到任何会引起问题的标点符号。

第二个要点,你可能没有注意到的是,视图处理完之后,cookie会被重新创建,我在创建一个新的cookie。据我了解,这应该会在浏览器中重置cookie的过期时间。

请注意以下几点:我在用 request.scope["session"] 进行更新。你不能直接更新 request.session,因为你需要更新请求对象中的 scope 字典。sessionauth 属性只会返回 obj.scope 中的值,没有设置方法。

最后说一点。具体的错误信息默认会有错误提示,表示中间件未加载,如果“auth”和“session”属性为空。请注意,你的中间件在这种情况下替换的是 Starlette 的中间件,别搞混了去加载 Starlette 的中间件。

身份验证中间件

Starlette设置了请求以包含“user”和“auth”属性,这是Starlette设置的。我觉得这样做挺合理的,所以我继续使用这些属性。

“auth”应该包含的是User模型中的“scope”字段的值的列表。我们稍后会用它来判断用户是否有访问某个端点的权限。

“user”在Starlette中使用BaseUser类。这样做很有用,因为它可以判断用户是否为已认证用户或匿名用户,并且使用is_authenticated属性来表示用户是否已认证。不过它还有些不太有用的属性或方法,所以我创建了自己的类来替换这些无用的部分。

from models import Session, Users

class BaseUser:
    @property
    def is_authenticated(self) -> bool:
        raise NotImplementedError()

class UnauthenticatedUser(BaseUser):
    @property
    def is_authenticated(self) -> bool:
        return False

class AuthUser(BaseUser):
    def __init__(self, session: Session) -> None:
        self.session = session
        self.__user = None

    @property
    def is_authenticated(self) -> bool:
        return True

    async def user(self) -> Users:
        if not self.__user:
            self.__user = await Users.get_or_none(id=self.session.user)
        return self.__user

我把它们放到了auth.init.py

这里就是我们说的中间件

@main_app.middleware("http")  
async def authentication_middleware(请求: Request, call_next):  
    token = 请求.cookies.get("session")  
    如果没有 token:  
        请求.scope["auth"] = ["匿名"]  
        请求.scope["user"] = 未认证用户()  
    否则:  
        session = await Session.get_or_none(token=token)  
        如果 session 为 None:  
            请求.scope["auth"] = ["匿名"]  
            请求.scope["user"] = 未认证用户()  
        否则:  
            请求.scope["user"] = 认证用户(session)  
            user = await 用户.get_or_none(id=session.user)  
            请求.scope["auth"] = user.scope  
    响应 = await call_next(请求)  
    返回 响应

需要注意的是,这仅会设置请求中的auth和user部分,而不做其他任何事情。

登录验证

我们需要一个功能来验证用户,并在验证通过的情况下创建会话记录,这基本上就是让他们进入已登录状态。

我在 auth.init.py 里面放了这个

async def authenticate(request: Request, user: str, password: str) -> bool:  
    user = await Users.get_or_none(username=user)  
    if user:  
        p_hash = hashlib.pbkdf2_hmac("sha256", password.encode(), user.p_salt, 100000)  
        if p_hash == user.p_hash:  
            expires = datetime.datetime.now() + datetime.timedelta(seconds=max_age)  
            if await Session.filter(token=request.session).exists():  
                await Session.filter(token=request.session).delete()  
            session = await Session.create(user=user.id, expires_at=expires,  
                                           scope=user.scope, token=request.session)  
            request.scope["session"] = session.token  
            request.scope["auth"] = user.scope  
            auth_user = AuthUser(session)  
            auth_user.__user = user  
            request.scope["user"] = auth_user  
            return True  
        else:  
            return False  
    else:  
        return False

这主要是用来检查用户的凭证,若有效,则创建会话记录并设置请求对象。清除现有会话记录是为了防止表中出现重复记录。

检查登录和访问权限

好的,用户已经发送了他们的用户名和密码。他们的用户名和密码已经过验证,并创建了一个会话记录。当他们向服务器发送请求时,请求对象会被更新为包含会话、认证和用户信息。现在我们需要检查这些信息,他们才能访问视图。

在 Django 中,我们会设置一个 logged_in 装饰器或类似的设计模式,但在 FastAPI 中,我们将使用依赖注入这种设计模式。我已经将它们添加到路径装饰器中。假设你熟悉依赖注入,并且会注意到路径装饰器中的依赖项只能传递参数或引发错误。你无法从依赖项中执行重定向。当然,FastAPI 会通过异常处理来解决这个问题,就像处理其他异常一样。

快速解释。

我们创建一个自定义异常,例如 NotLoggedInException。它只需要有一个唯一的名称,其他没有特别要求,简单明了。

定义了一个名为NotLoggedInException的异常类,当用户未登录时抛出此异常。这个类继承了Exception类,并且它的__init__方法暂时不做任何事情。

我们这个依赖性检查请求对象,并在用户没有登录的话来抛出异常。

    async def 已登录(request: Request, background_tasks: BackgroundTasks) -> bool:  
        if not request.user.is_authenticated:  
            raise 未登录异常()  
        background_tasks.add_task(update_session_expiry, request)  
        return True

# 此函数检查用户是否已登录,如果未登录则抛出异常,并更新会话过期时间。

我们的视图功能由于未登录异常(NotLoggedInException)而失败。

我们可以通过FastAPI来捕获这个异常,利用它的异常处理功能。

    def 未登录处理程序(request: Request, exc: NotLoggedInException):  
        """当需要登录时,重定向到登录页面。"""
        # RedirectResponse 用于重定向到指定页面
        return RedirectResponse("/auth")
    app.add_exception_handler(未登录异常, 未登录处理函数)

所以现在我们的依赖关系抛出了 NotLoggedInException。FastAPI 捕捉到异常,然后运行 no_logged_in_handler()。进而让用户跳转到登录页面。

所以我们现在可以编写一系列的依赖关系、自定义异常和异常处理程序,来处理我们的认证和权限验证。

    from auth.exceptions import NotLoggedInException, PermissionFailedException, AlreadyLoggedInException  
    from fastapi import Request, BackgroundTasks  
    import datetime  
    from settings import max_age  # 最大时间间隔, 单位为秒  
    from models import Session  

    async def update_session_expiry(request: Request):  
        session = await Session.get(token=request.session)  # 获取会话  
        session.expires_at = datetime.datetime.now() + datetime.timedelta(seconds=max_age)  # 更新会话过期时间  
        await session.save()  # 保存会话  
        await Session.filter(expires_at__lt=datetime.datetime.now()).delete()  # 删除过期会话  

    async def already_logged_in(request: Request):  
        if request.user.is_authenticated:  
            raise AlreadyLoggedInException()  # 用户已登录,抛出异常  
        return True  

    async def logged_in(request: Request, background_tasks: BackgroundTasks) -> bool:  
        if not request.user.is_authenticated:  
            raise NotLoggedInException()  # 用户未登录,抛出异常  
        background_tasks.add_task(update_session_expiry, request)  # 更新会话过期时间  
        return True  

    async def admin_user(request: Request, background_tasks: BackgroundTasks) -> bool:  
        if not request.user.is_authenticated:  
            raise NotLoggedInException()  # 用户未登录,抛出异常  
        if "admin" not in request.auth or "super" not in request.auth:  
            raise PermissionFailedException("您需要管理员权限才能访问此端点")  # 权限不足,抛出异常  
        background_tasks.add_task(update_session_expiry, request)  # 更新会话过期时间  
        return True  

    async def super_user(request: Request, background_tasks: BackgroundTasks) -> bool:  
        if not request.user.is_authenticated:  
            raise NotLoggedInException()  # 用户未登录,抛出异常  
        if "super" not in request.auth:  
            raise PermissionFailedException("您需要超级用户权限才能访问此端点")  # 权限不足,抛出异常  
        background_tasks.add_task(update_session_expiry, request)  # 更新会话过期时间  
        return True
    从 fastapi 响应 导入 RedirectResponse, HTMLResponse  
    从 fastapi 导入 Request, FastAPI  

    """  
    自定义认证异常及其处理程序  
    """  

    class NotLoggedInException(Exception):  
        def __init__(self):  
            pass  

    class AlreadyLoggedInException(Exception):  
        def __init__(self):  
            pass  

    def already_logged_in_handler(request: Request, exc: AlreadyLoggedInException):  
        return RedirectResponse("/")  

    def not_logged_in_handler(request: Request, exc: NotLoggedInException):  
        """如果需要登录,重定向到登录页面 /auth"""  
        return RedirectResponse("/auth")  

    class PermissionFailedException(Exception):  
        def __init__(self, permissions: list):  
            self.permissions = permissions  

    def permission_failed_handler(request: Request, exc: PermissionFailedException):  
        """如果认证范围不足,显示错误页面"""  
        return HTMLResponse(content="templates/permission_failed.html", status_code=401)  

    def 添加认证异常处理(app: FastAPI):  
        """加载异常处理程序到应用程序中"""  
        app.add_exception_handler(NotLoggedInException, not_logged_in_handler)  
        app.add_exception_handler(PermissionFailedException, permission_failed_handler)  
        app.add_exception_handler(AlreadyLoggedInException, already_logged_in_handler)

还有两件事没提到。首先,我创建了一个 auth_exceptions_add() 函数,这样异常可以在 main.py 之外进行处理,从而实现了一定程度的分离。

在依赖项中使用BackgroundTask对象。这样做可以不影响视图的性能,并更新会话记录的过期日期。

结论部分:

我开始这个项目时以为可以从Starlette中借用一些代码元素,但实际上,我发现所有不兼容的地方,说明FastAPI和Starlette并不相同。仅仅因为它们存在于Starlette中,并不意味着它们在FastAPI中就能用。

然而,FastAPI 将我认为会很复杂的项目变得相对容易创建。添加我自己的中间件、依赖项和处理程序虽然不算简单,但相对而言还是比较容易且非常有趣。虽然 FastAPI 可能不像其他框架那样自带很多功能,但它提供了相对简单的途径来自己实现这些功能。

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消