fastapi配合tortoise-orm实现jwt以及rbac的教程

本篇博客总结了使用fastapi和tortoise-orm来实现JWT和rbac的一些思路和方法,不适合完全没有基础的小伙伴来学习,推荐掌握tortoise-orm简单的增删改查操作,再先阅读完FastAPI安全性章节的相关文档后再来阅读本篇博客,相信你会有收获。

安全性 - FastAPI (tiangolo.com)

项目概述

  • fastapi
  • tortoise-orm
  • jwt
  • rbac

使用fastapi搭建后端服务器应用,并使用tortoise-orm操作数据库,项目内容是实现jwt登录和权限校验,提供构建一个fastapi应用的基本思路

数据库设计

rbac最简单的五表结构,通过user、role、permission三张表以及user-role、role-permission两张多对多关系表来实现权限的分配

在这里插入图片描述

from tortoise.models import Model
from tortoise import fields


class TimestampMixin(Model):
    create_time = fields.DatetimeField(auto_now_add=True, description='创建时间')
    update_time = fields.DatetimeField(auto_now=True, description="更新时间")

    class Meta:
        abstract = True


class User(TimestampMixin):
    user_name = fields.CharField(max_length=20,unique=True)
    role: fields.ManyToManyRelation["Role"] = \
        fields.ManyToManyField("models.Role", related_name="user", on_delete=fields.CASCADE)
    password = fields.CharField(max_length=255)
    user_status = fields.IntField(default=0, description='0未激活 1正常 2禁用')
    super_admin = fields.BooleanField(default=False, description="用户类型 True:超级管理员 False:普通用户")


class Role(TimestampMixin):
    role_name = fields.CharField(max_length=20,unique=True)
    user: fields.ManyToManyRelation[User]
    permission: fields.ManyToManyRelation["Permission"] = \
        fields.ManyToManyField("models.Permission", related_name="role", on_delete=fields.CASCADE)


class Permission(TimestampMixin):
    scope = fields.CharField(max_length=30,unique=True)
    role: fields.ManyToManyRelation[Role]

核心知识

user、role、permission单表的增删改查没啥好说的,核心知识是我们要会使用tortoise-orm对多对多关系进行最基本的增删改查

因为查询的结果常常是一个[ queryset ],遍历完之后,我们一般不会带着数据的创建时间或者一些我们不想返回给前端的数据,所以要配合schemas(注意这里包括后面我说的schemas指的是pydantic模型,fastapi用pydantic模型做序列化和校验,但是不是本文讲解的重点,所以不做追述),做一个反序列化,所以说我们可以写一个工具来帮我你们做这件事情。

utils.deserialize.py

from typing import Type
from pydantic import BaseModel


def util_list_queryset_validate_dump(data:list,schemas:Type[BaseModel]) -> list:
    """
    解析被list包裹的queryset,并使用pydantic进行校验和反序列化
    :param schemas:
    :param data:
    :return:
    """
    data_list = []
    for i in data:
        instance = schemas(**i)
        model_dump = getattr(instance, "model_dump", None)
        if callable(model_dump):
            data_list.append(model_dump())
        else:
            pass
    return data_list

接下来就是我们对多对多表单的基本操作演示,我做了几个API作为示范

API.relation.py

from fastapi import APIRouter
from models.models import Role,Permission,User
from schemas import schemas
from utils.deserialize import util_list_queryset_validate_dump


router = APIRouter()

#role_permissions--------------------------------------------------------------
@router.post('/role_permission',summary='角色绑定权限')
async def create_role_permission(data:schemas.RelationRolePermissionIn):
    role = await Role.get_or_none(pk = data.role_id)
    await role.permission.clear()
    permissions = await Permission.filter(id__in= data.permission_id)
    await role.permission.add(*permissions)
    return 'CreateRolePermission'


@router.get('/role_permission/{role_id}',summary='查询角色所有权限')
async def get_all_role_permissions(role_id:int):
    role = await Role.get_or_none(pk = role_id)
    result =  await role.permission.all().values()
    data_list = util_list_queryset_validate_dump(result,schemas.PermissionOut)
    return (data_list)


@router.delete('/role_permission/clear/{role_id}',summary='清空角色权限')
async def clear_role_permissions(role_id:int):
    role = await Role.get_or_none(pk = role_id)
    await role.permission.clear()
    return "ClearRolePermission"
#------------------------------------------------------------------------------


#user_roles--------------------------------------------------------------------
@router.post('/user_role',summary='用户绑定角色')
async def createUserRole(data:schemas.RelationUserRoleIn):
    user = await User.get_or_none(pk = data.user_id)
    await user.role.clear()
    role = await Role.filter(id__in= data.role_id)
    await user.role.add(*role)
    return 'CreateUserRole'


@router.get('/user_role/{role_id}',summary='查询用户所有角色')
async def getAllUserRole(user_id:int):
    user = await User.get_or_none(pk = user_id)
    result =  await user.role.all().values()
    data_list = util_list_queryset_validate_dump(result,schemas.RoleOut)
    return (data_list)


@router.delete('/user_role/clear/{role_id}',summary='清空用户所有角色')
async def clearUserRole(role_id:int):
    user = await User.get_or_none(pk = role_id)
    await user.role.clear()
    return "ClearRolePermission"
#------------------------------------------------------------------------------
@router.get('/user_permission/{user_id}',summary='查询用户所有权限')
async def getAllUserRole(user_id:int):
    data = await Permission.filter(role__user__id=user_id).all().values_list()
    return data

JWT与权限校验

流程概述

  • 登录接口:接收前端应用发来的表单请求并返回jwt-token
  • 权限校验:通过依赖注入的方式校验每条请求用户携带的token,以及用户所拥有的权限范围(scope)

核心知识

fastapi实现JWT与权限校验是通过依赖注入的方式来实现,重点是怎么构建校验JWT和权限范围(scopes)的依赖。

构建校验JWT和权限范围(scopes)的依赖,很简单只需要我们使用fastapi我们封装好的一些工具

  • OAuth2PasswordBearer

    OAuth2PasswordBearer用于获取用户Authorization请求头中的token,并且会校验请求头的值是不是由 "Bearer "+ token组成,总而言之它会返回用户在请求头中传递的token并做校验,我们构建的依赖要依赖它来得到token

  • SecurityScopes

    我们在要为路径绑定权限范围(scopes),当请求进来的时候,我们需要获取到这个路径上我们绑定的scopes参数是什么,SecurityScopes就能帮助我们非常便捷的拿到,路径上绑定的socpes,然后我们在去数据库检查用户是否具有该路径所需要的socpes,这就实现了权限校验

  • Security

    如果我们要使用SecurityScopes来获取路径上的scopes,我们就不使用Depends来为路径注入依赖了,而是使用Security,它与Depends一样具有依赖注入的功能,不同的是它比Depends多一个scopes参数,通过这个参数我们来设定路径的权限范围,在校验的过程中我们又能通过SecurityScopes来获取到路径上设定的scopes

我们需要构建一个依赖,我们希望这个依赖具有两个功能

  • 获取用户传递的token的功能
  • 获取路径所需要的scopes的功能

在此基础上我们对拿到的token进行解析拿到用户id,用id从数据库了取出用户数据,看看用户数据中拥有的scopes是否包含路径所需要的socpes

你可以想象数据库中保存的用户scopes是用户的一串钥匙,路径中需要的scopes是一组锁,我们要校验用户拥有的钥匙是否能开启全部的锁,不能开启全部的锁我们就认为用户没有使用这个API的权力,就要给用户返回错误。

示例

综上所述,我们现在需要做三件事情

  • 做一个用户登录和注册的API
  • 做一个校验用户token和用户权限(scopes)的依赖
  • 做一个需要权限校验的测试API(被注入了我们构建的校验依赖的API)

封装JWT解析工具和给用户密码做hash和校验的工具

使用的库是

  • python-jose jwt工具
  • passlib hash工具
pip install python-jose[cryptography]
pip install passlib[bcrypt]
from passlib.context import CryptContext
from typing import Union
from jose import  jwt
from datetime import datetime, timedelta, timezone
from fastapi import  HTTPException
from settings import SECRET_KEY,ALGORITHM
from models.models import User

#hashtools-------------------------------------------------------------------
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


#hash校验工具
def util_verify_password(plain_password, hashed_password):
    return pwd_context.verify(plain_password, hashed_password)


#hash创建工具
def util_get_password_hash(password):
    return pwd_context.hash(password)

async def  util_validate_user(username,password):
    user = await User.get_or_none(user_name = username)
    if not user:
        raise HTTPException(status_code=400, detail="Incorrect username or password")
    if not util_verify_password(password, user.password):
        raise HTTPException(status_code=400, detail="Incorrect username or password")
    return user

#jwt_token_maker-------------------------------------------------------------
def create_access_token(data: dict, expires_delta: Union[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

注册和登录接口

1注册接口,比较简单不做赘述

@router.post('',summary='创建用户(用户注册)')
async def create_user(data:schemas.UserIn):
    data.password = util_get_password_hash(data.password)
    result = await User.create(**data.model_dump())
    return dict(result)

2登录接口

这里我们使用了一个OAuth2PasswordRequestForm,这是fastapi给我们封装好的的一个符合Oauth2标准的一个登录表单接口,其实你也可以自己写一个登录接口没啥区别,只是schemas不用你自己写而已,没有更多的需求的话用它来快速做一个登录接口也是很方便

from typing import Annotated
from datetime import timedelta
from fastapi.security import OAuth2PasswordRequestForm
from fastapi import Depends,APIRouter
from schemas.schemas import Token,UserOut
from utils.jwt import create_access_token,util_validate_user
from utils.deserialize import util_list_queryset_validate_dump
from settings import ACCESS_TOKEN_EXPIRE_MINUTES
from schemas import schemas


router = APIRouter()
@router.post("/token")
async def login_for_access_token(
    form_data: Annotated[OAuth2PasswordRequestForm, Depends()]
) -> Token:
    print(form_data.username)
    print(form_data.password)
    user = await util_validate_user(form_data.username,form_data.password)
    access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    #neederrorhandle
    user_data =  UserOut(**dict(user)).model_dump()
    result = await user.role.all().values()
    data_list = util_list_queryset_validate_dump(result, schemas.RoleIn)
    data_list_out = []
    for i in data_list:
        data_list_out.append(i["role_name"])
    user_data['role'] = data_list_out
    print(user_data)
    access_token = create_access_token(
        data= user_data,expires_delta=access_token_expires)
    return Token(access_token=access_token, token_type="bearer")

构建校验依赖

dependencies.py

from typing import Annotated
from pydantic import BaseModel, ValidationError
from jose import JWTError, jwt
from fastapi.security import OAuth2PasswordBearer,SecurityScopes
from fastapi import Depends,  HTTPException, status,Request
from settings import SECRET_KEY,ALGORITHM
from models.models import User,Permission


#auth-------------------------------------------------------------------------------------------
"""
对OAuth2PasswordBearer实例化,参数是帮助我们在fastapi交互式文档中生成调试工具的,也就是在fastapi交互式api文档里面,给我们自动生成了一个登录工具
"""
oauth2_scheme = OAuth2PasswordBearer(
    tokenUrl="login/token",
    scopes={"me": "permisson_me"},
)


class TokenData(BaseModel):
    user_id: int
    scopes: list[str] = []


"""
创建一个依赖,这个依赖依赖于上面的oauth2_scheme,并为它传入SecurityScopes使其能获取路径上的scopes
基本流程就是拿到token进行解析,取出用户数据到数据库进行校验,校验失败返回错误
"""
async def auth_dependence(
request:Request,security_scopes: SecurityScopes, token: Annotated[str, Depends(oauth2_scheme)]
):
    if security_scopes.scopes:
        authenticate_value = f'Bearer scope="{security_scopes.scope_str}"'
    else:
        authenticate_value = "Bearer"
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": authenticate_value},
    )

    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        user_id: str = payload.get("user_id")
        if not user_id :
            raise credentials_exception
        token_scopes = payload.get("scopes", [])
        token_data = TokenData(scopes=token_scopes, user_id=user_id)
    except (JWTError,ValidationError):
        raise credentials_exception
    user = await User.get(pk = token_data.user_id)
    if user.user_status == 2:
        raise credentials_exception
    if not user:
        raise credentials_exception
    print(user.super_admin)
    if  security_scopes.scopes and not user.super_admin:
        is_pass = await Permission.filter(
            role__user__id=user_id, scope__in=set(security_scopes.scopes)).all()
        if not is_pass:
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail="Not enough permissions",
                headers={"WWW-Authenticate": authenticate_value},
            )
    request.state.user_id = user_id
    request.state.super_admin = user.super_admin

使用我们创建的依赖

在路径中注入我们做好的依赖,当用户请求进来的时候,校验的流程就在依赖里发生了

api.scope_test.py

from fastapi import APIRouter,Request,Security
from dependencies import auth_dependence

router = APIRouter()

@router.get('/scope_test',summary='scope权限测试',dependencies=[Security(auth_dependence,scopes=["use_test"])])
async def super_admin_test():
    return "权限通过"

异常捕获

我们为了保持设计API时的专注,因此不在API里对数据库的各种异常做处理,我们可以通过starlette(fastapi继承了starlette)为我们提供的api做一些全局的异常捕获

我们先定义一组异常处理方法

utils.exceptions.py

from fastapi import HTTPException, Request, status
from fastapi.responses import JSONResponse
from typing import Union
from fastapi.exceptions import RequestValidationError
from pydantic import ValidationError
from tortoise.exceptions import OperationalError, DoesNotExist, IntegrityError, ValidationError as MysqlValidationError


async def mysql_validation_error(_: Request, exc: MysqlValidationError):
    """
    数据库字段验证错误
    :param _:
    :param exc:
    :return:
    """
    print("ValidationError", exc)
    return JSONResponse({
        "code": -1,
        "message": exc.__str__(),
        "data": []
    }, status_code=422)


async def mysql_integrity_error(_: Request, exc: IntegrityError):
    """
    完整性错误
    :param _:
    :param exc:
    :return:
    """
    print("IntegrityError", exc)
    return JSONResponse({
        "code": -1,
        "message": exc.__str__(),
        "data": []
    }, status_code=422)


async def mysql_does_not_exist(_: Request, exc: DoesNotExist):
    """
    mysql 查询对象不存在异常处理
    :param _:
    :param exc:
    :return:
    """
    print("DoesNotExist", exc)
    return JSONResponse({
        "code": -1,
        "message": "发出的请求针对的是不存在的记录,服务器没有进行操作。",
        "data": []
    }, status_code=404)


async def mysql_operational_error(_: Request, exc: OperationalError):
    """
    mysql 数据库异常错误处理
    :param _:
    :param exc:
    :return:
    """
    print("OperationalError", exc)
    return JSONResponse({
        "code": -1,
        "message": "数据操作失败",
        "data": []
    }, status_code=500)


async def http_error_handler(_: Request, exc: HTTPException):
    """
    http异常处理
    :param _:
    :param exc:
    :return:
    """
    if exc.status_code == 401:
        return JSONResponse({"detail": exc.detail}, status_code=exc.status_code)

    return JSONResponse({
        "code": exc.status_code,
        "message": exc.detail,
        "data": exc.detail
    }, status_code=exc.status_code, headers=exc.headers)


class UnicornException(Exception):

    def __init__(self, code, errmsg, data=None):
        """
        失败返回格式
        :param code:
        :param errmsg:
        """
        if data is None:
            data = {}
        self.code = code
        self.errmsg = errmsg
        self.data = data


async def unicorn_exception_handler(_: Request, exc: UnicornException):
    """
    unicorn 异常处理
    :param _:
    :param exc:
    :return:
    """
    return JSONResponse({
        "code": exc.code,
        "message": exc.errmsg,
        "data": exc.data,
    })


async def http422_error_handler(_: Request, exc: Union[RequestValidationError, ValidationError],) -> JSONResponse:
    """
    参数校验错误处理
    :param _:
    :param exc:
    :return:
    """
    print("[422]", exc.errors())
    return JSONResponse(
        {
            "code": status.HTTP_422_UNPROCESSABLE_ENTITY,
            "message": f"数据校验错误 {exc.errors()}",
            "data": exc.errors(),
        },
        status_code=422,
    )

然后给使用add_exception_handler方法配置进app里就行

import uvicorn
from fastapi import FastAPI,HTTPException
from fastapi.exceptions import RequestValidationError
from settings import TORTOISE_ORM,ALLOWHOSTS
from tortoise.contrib.fastapi import register_tortoise
from API import permissions,role,user,relation,login,scope_auth_test
from tortoise.exceptions import OperationalError, DoesNotExist, IntegrityError, ValidationError
from utils import exception
from fastapi.middleware.cors import CORSMiddleware


app = FastAPI()


app.include_router(user.router, prefix='/user', tags=['用户API'])
app.include_router(role.router, prefix='/role', tags=['角色API'])
app.include_router(permissions.router, prefix='/permissions', tags=['权限API'])
app.include_router(relation.router,prefix='/user',tags=['关系API'])
app.include_router(login.router,prefix='/login',tags=['登录'])
app.include_router(scope_auth_test.router,prefix='/test',tags=['权限测试'])


#异常捕获在这里
app.add_exception_handler(HTTPException, exception.http_error_handler)
app.add_exception_handler(RequestValidationError, exception.http422_error_handler)
app.add_exception_handler(exception.UnicornException, exception.unicorn_exception_handler)
app.add_exception_handler(DoesNotExist, exception.mysql_does_not_exist)
app.add_exception_handler(IntegrityError, exception.mysql_integrity_error)
app.add_exception_handler(ValidationError, exception.mysql_validation_error)
app.add_exception_handler(OperationalError, exception.mysql_operational_error)


register_tortoise(
    app=app,
    config=TORTOISE_ORM
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=ALLOWHOSTS,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

if __name__ == "__main__":
    uvicorn.run('main:app', host='127.0.0.1', port=8000, reload=True, workers=1)



  • 25
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值