🔍FastAPI认证授权设计,token怎么生成(六)

791 阅读7分钟

前言

其实早就想写一篇了,但是有很多因素,今天总算搞定了。本篇主要就是如何用fastapi设计一个实际可用的认证授权体系的步骤和思路。网上很多写的比较简单,demo都不算。很多都不用真实数据库。token的验证获取啥的都很简陋。根本就是用不了一点。

最后的项目代码已经上传到github上,可以先按步骤自己尝试,最后可对照学习。

数据库表创建

先创建一个数据库名字是study

image.png 然后执行脚本

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for users
-- ----------------------------
DROP TABLE IF EXISTS `users`;
CREATE TABLE `users`  (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `username` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
  `password` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
  `address` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
  `age` bigint(20) NULL DEFAULT NULL,
  `phone` varchar(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
  `score` bigint(20) NULL DEFAULT NULL,
  `dept` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
  `created_at` datetime(3) NULL DEFAULT NULL,
  `updated_at` datetime(3) NULL DEFAULT NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 412 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of users
-- ----------------------------
INSERT INTO `users` VALUES (407, 'aa', '1111', '地址1', 25, '12345678901', 80, '部门1', '2024-02-02 09:44:29.000', '2024-02-02 09:44:29.000');
INSERT INTO `users` VALUES (408, '李四', 'password2', '地址2', 30, '12345678902', 90, '部门2', '2024-02-02 09:44:29.000', '2024-02-02 09:44:29.000');
INSERT INTO `users` VALUES (409, '王五', 'password3', '地址3', 35, '12345678903', 70, '部门1', '2024-02-02 09:44:29.000', '2024-02-02 09:44:29.000');
INSERT INTO `users` VALUES (410, '赵六', 'password4', '地址4', 28, '12345678904', 85, '部门2', '2024-02-02 09:44:29.000', '2024-02-02 09:44:29.000');
INSERT INTO `users` VALUES (411, '钱七', 'password5', '地址5', 32, '12345678905', 75, '部门1', '2024-02-02 09:44:29.000', '2024-02-02 09:44:29.000');

SET FOREIGN_KEY_CHECKS = 1;

image.png

认证授权相关

安装依赖

poetry add pyjwt@latest

目前新项目很多都是poetry项目。我这边使用也用poetry,如果用requirements的项目可以换成pip install

登录

正常是注册开始。但是注册就是给数据库存一条用户记录。上面脚本直接插入了,也不是核心,跳过。下面会按步骤讲解。

@router.post("/login")
async def login(data:Users=Body(Users)):
    # 1.从数据库获取user信息
    user = await Users.where(username=data.username).get()
    # 2.校验密码
    if user is None or user['password'] != data.password:
        raise HTTPException(status_code=401,detail="用户名或密码错误")
    # 3.生成token
    access_token = create_access_token(user)
    # 4.返回token
    return AppResult.success({"access_token": access_token})
  • 步骤一:查询操作数据的orm不用关心。由于网上的orm使用比较繁琐,为了不影响本篇文章的核心认证授权讲解,我自己封装了一个简单的库。让大家不至于被orm的代码影响到阅读体验。

  • 步骤二:主要是匹配密码正确性的。这个地方进一步处理就是可以采用加盐加密的方式去对密码处理,更安全

  • 步骤三:这个地方的逻辑主要是使用user账号信息生成一个token

  • 步骤四:返回token给前端存下来。下一篇会将前端的token存储封装。涉及app和pc.

AppResult.success 是对返回数据的一个封装。减少写一些冗余的状态码代码。

auth工具类

写一个auth.py,里面专门处理认证鉴权的代码

create_access_token函数

# 配置 JWT
SECRET_KEY = "laAuth"
ALGORITHM = "HS256"

def create_access_token(data: dict):
    expires_delta = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    expire = datetime.now(timezone.utc) + expires_delta     
    encoded_jwt = jwt.encode({"id": data.get("id"),"username": data.get("username"), "exp": expire}, SECRET_KEY, algorithm=ALGORITHM)
    return encoded_jwt

HS256是加密算法在性能和安全性都比较好。一般用这个

然后根据你需要存储的user数据和加密密钥调方法就可以生成一个jwt的token了。然后就可以使用了

image.png

使用redis优化

失效时间

如果token都不失效,那么token泄露,没有安全性可言,而失效时间,redis天然支持。方便性能佳。

频繁生成token

如果你疯狂再次请求login的接口,这个时候,不应该在返回新的token浪费性能。而且更换token信息也会导致其他的人被登出。而这个是通过redis给token加个失效时间。然后登录判断我的token是否还在,在就直接返回。不在才创建。

安装依赖

poetry add redis@latest

封装一个redis工具类

# 配置 Redis
import redis


REDIS_HOST = "localhost"
REDIS_PORT = 6379
REDIS_DB = 0
REDIS_PASSWORD = ""
# 初始化 Redis 客户端
redisTool = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB, password=REDIS_PASSWORD if REDIS_PASSWORD else None)

改造create_token

# 配置 JWT
SECRET_KEY = "laAuth"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
def create_access_token(data: dict):
    expires_delta = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    expire = datetime.now(timezone.utc) + expires_delta   
    encoded_jwt = jwt.encode({"id": data.get("id"),"username": data.get("username"), "exp": expire}, SECRET_KEY, algorithm=ALGORITHM)
    # 保存在auth目录下的key
    redisTool.set(encoded_jwt, data.get("id"), ex=ACCESS_TOKEN_EXPIRE_MINUTES*60)
    redisTool.set(data.get("id"), encoded_jwt, ex=ACCESS_TOKEN_EXPIRE_MINUTES*60)
    return encoded_jwt

频繁创建新token只需要判断下是否有,改造login接口

 @router.post("/login")
 async def login(data:Users=Body(Users)):
   # 1.获取yo
   user = await Users.where(username=data.username).get()
   # 2.校验密码
   if user is None or user['password'] != data.password:
       raise HTTPException(status_code=401,detail="用户名或密码错误")
   access_token = redisTool.get(user['id'])
   if access_token is not None:
       return AppResult.success({"access_token": access_token.decode("utf-8")})
   # 3.生成token
   access_token = create_access_token(user)
   # 4.返回token
   return AppResult.success({"access_token": access_token})

双向存储设计可以提高查询性能和响应速度

用中间件机制来处理鉴权拦截

将鉴权逻辑放在中间件中,可以避免在每个路由处理函数中写重复相同的代码

编写逻辑处理类

whitePath=["/","/docs", "/redoc", "/openapi.json","/auth/login", "register"]

class AuthenticationMiddleware(BaseHTTPMiddleware):
    def __init__(self, app: FastAPI):
        super().__init__(app)
    async def dispatch(
        self, request: Request, call_next: Callable[[Request], Coroutine[Any, Any, Response]]
    ) -> Response:
        # 1. 判断当前请求的路径是否在白名单中,如果是,则直接返回响应
        path = request.url.path
        if path in whitePath:
            response = await call_next(request)
            return response
        # 2. 获取请求头中的token,如果没有,则返回401
        token = request.headers.get("Authorization")
        if not token:
            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
        # 3. 解析token,如果解析失败,则返回401
        user = verify_token(token)
        request.state.user = user
        response = await call_next(request)
        return response

verify_token

def verify_token(token: str):
    # 从 Redis 中检查 token 是否存在
    if not redisTool.exists(token):
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未登录")
    # 解析token后再次验证是否过期
    user = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
    if not user or user.get("exp", 0) < datetime.now(timezone.utc).timestamp():
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
    return user   

注册中间件

# 添加认证中间件
app.add_middleware(AuthenticationMiddleware)

验证授权成功

@router.get("/info")
async def read_users_me(request: Request):
    user = request.state.user
    if user is None:
        return AppResult.fail(status.HTTP_401_UNAUTHORIZED, "用户未登录")
    return AppResult.success(user, "操作成功")

上一步骤我们知道在request.state.user有我们存储的user信息。我们可以通过api请求在header填写上token就可以验证,得到当前得登录信息

image.png

封装全局context

现在虽然我们在request里面能够获取user的信息,但是如果需要做到到处使用。现在特别不方便,需要手动注入,或方法传参。

在我们工具类生成context对象

# 创建一个上下文变量来存储 request 对象
current_request_var = contextvars.ContextVar('current_request')

class AuthenticationMiddleware(BaseHTTPMiddleware):
    def __init__(self, app: FastAPI):
        super().__init__(app)
    async def dispatch(
        self, request: Request, call_next: Callable[[Request], Coroutine[Any, Any, Response]]
    ) -> Response:
        xxxx
        xxxx
        # 4. 设置当前请求的用户信息到上下文变量中,并返回响应
        request.state.user = user
        # 5.设置上下文变量
        current_request_var.set(request)   

usercontext类

class UserContext:
    @staticmethod
    def getUser():
        request = current_request_var.get(None)  # 获取上下文变量
        if request is None:
            raise HTTPException(status_code=400, detail="未知错误")
        user = getattr(request.state, 'user', None)
        if user is None:
            raise HTTPException(status_code=401, detail="用户未登录")
        return user

改造我们的api接口

@router.get("/info")
async def read_users_me():
    user = UserContext.getUser()
    if user is None:
        return AppResult.fail(status.HTTP_401_UNAUTHORIZED, "用户未登录")
    return AppResult.success(user, "操作成功")

改造前

image.png

改造后

image.png

不用依赖注入就能获取到当前上下文的用户信息,任意地方可调用。最后结果和上面一致。 github.com/mryzhou/cod…

为了讲基本的一个框架,便于理解思路,很多小优化没写,后续可以自己补充

优化点:

  • 密码采用加盐加密,可以避免数据库泄露造成的安全问题
  • 日志记录与监控
  • 异步 Redis

上面是基础版本,实际上还有升级版