From 78cc7e3f54e98c506b4527af8df209d7cabb7636 Mon Sep 17 00:00:00 2001 From: proDream Date: Tue, 29 Apr 2025 22:51:00 +0400 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20=D1=84?= =?UTF-8?q?=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=B8=20=D0=B8=20=D1=81=D1=85?= =?UTF-8?q?=D0=B5=D0=BC=D1=8B=20=D0=B2=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB?= =?UTF-8?q?=D1=8C=20auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлен класс UserVerifySchema для валидации данных пользователя, наследующий GetUserByID и GetUserByEmail. - Обновлена модель AuthUser для регистрации пользователя. - Добавлены роуты logout и get-user для работы с аутентификацией. - Создан новый модуль utils с функцией get_token_from_cookies для извлечения токена из куки. - Добавлена логика для выхода пользователя и получения информации об авторизованном пользователе в сервисах и роутах. - Обновлены схемы и зависимости в модуле handlers и managers для работы с аутентификацией. --- lkeep/apps/auth/depends.py | 52 +++++++++++++++++++++++++++++++++++ lkeep/apps/auth/handlers.py | 21 ++++++++++++++ lkeep/apps/auth/managers.py | 55 ++++++++++++++++++++++++++++++++++++- lkeep/apps/auth/routes.py | 37 ++++++++++++++++++++++++- lkeep/apps/auth/schemas.py | 11 ++++++++ lkeep/apps/auth/services.py | 26 ++++++++++++++++-- lkeep/apps/auth/utils.py | 27 ++++++++++++++++++ tests/__init__.py | 0 8 files changed, 225 insertions(+), 4 deletions(-) create mode 100644 lkeep/apps/auth/depends.py create mode 100644 lkeep/apps/auth/utils.py create mode 100644 tests/__init__.py diff --git a/lkeep/apps/auth/depends.py b/lkeep/apps/auth/depends.py new file mode 100644 index 0000000..272f3be --- /dev/null +++ b/lkeep/apps/auth/depends.py @@ -0,0 +1,52 @@ +""" +Проект: Lkeep +Автор: Иван Ашихмин +Год: 2025 +Специально для проекта "Код на салфетке" +https://pressanybutton.ru/category/servis-na-fastapi/ +""" + +import uuid +from typing import Annotated + +from fastapi import Depends, HTTPException +from starlette import status + +from lkeep.apps.auth.handlers import AuthHandler +from lkeep.apps.auth.managers import UserManager +from lkeep.apps.auth.schemas import UserVerifySchema +from lkeep.apps.auth.utils import get_token_from_cookies + + +async def get_current_user( + token: Annotated[str, Depends(get_token_from_cookies)], + handler: AuthHandler = Depends(AuthHandler), + manager: UserManager = Depends(UserManager), +) -> UserVerifySchema: + """ + Получает текущего пользователя из токена аутентификации. + + :param token: Токен аутентификации, полученный из куки. + :type token: str + :param handler: Обработчик аутентификации, использующийся для декодирования токена. + :type handler: AuthHandler + :param manager: Менеджер пользователей, используется для проверки и получения данных о пользователе. + :type manager: UserManager + :returns: Схема с информацией о текущем пользователе. + :rtype: UserVerifySchema + :raises HTTPException: Если токен невалиден или пользователь не найден. + """ + decoded_token = await handler.decode_access_token(token=token) + user_id = str(decoded_token.get("user_id")) + session_id = str(decoded_token.get("session_id")) + + if not await manager.get_access_token(user_id=user_id, session_id=session_id): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token is invalid") + + user = await manager.get_user_by_id(user_id=uuid.UUID(user_id)) + if user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") + + user.session_id = session_id + + return user diff --git a/lkeep/apps/auth/handlers.py b/lkeep/apps/auth/handlers.py index 7292e23..4eb683d 100644 --- a/lkeep/apps/auth/handlers.py +++ b/lkeep/apps/auth/handlers.py @@ -10,7 +10,9 @@ import datetime import uuid import jwt +from fastapi import HTTPException from passlib.context import CryptContext +from starlette import status from lkeep.apps.auth.named_tuples import CreateTokenTuple from lkeep.core.settings import settings @@ -70,3 +72,22 @@ class AuthHandler: encoded_jwt = jwt.encode(payload=data, key=self.secret, algorithm="HS256") return CreateTokenTuple(encoded_jwt=encoded_jwt, session_id=session_id) + + async def decode_access_token(self, token: str) -> dict: + """ + Декодирует JWT-токен и возвращает его содержимое. + + :param token: Строка с JWT-токеном, который нужно декодировать. + :type token: str + :returns: Данные, содержащиеся в декодированном токене. + :rtype: dict + :raises HTTPException: При ошибке декодирования токена (например, токен просрочен или невалиден). + Статус-код ответа 401 UNAUTHORIZED, детализация "Token has expired" при просрочке, + и "Invalid token" при недопустимости токена. + """ + try: + return jwt.decode(jwt=token, key=self.secret, algorithms=["HS256"]) + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token has expired") + except jwt.InvalidTokenError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") diff --git a/lkeep/apps/auth/managers.py b/lkeep/apps/auth/managers.py index 01f9da5..c6baaeb 100644 --- a/lkeep/apps/auth/managers.py +++ b/lkeep/apps/auth/managers.py @@ -12,7 +12,12 @@ from fastapi import Depends, HTTPException from sqlalchemy import insert, select, update from sqlalchemy.exc import IntegrityError -from lkeep.apps.auth.schemas import CreateUser, GetUserWithIDAndEmail, UserReturnData +from lkeep.apps.auth.schemas import ( + CreateUser, + GetUserWithIDAndEmail, + UserReturnData, + UserVerifySchema, +) from lkeep.core.core_dependency.db_dependency import DBDependency from lkeep.core.core_dependency.redis_dependency import RedisDependency from lkeep.database.models import User @@ -93,6 +98,27 @@ class UserManager: return None + async def get_user_by_id(self, user_id: uuid.UUID | str) -> UserVerifySchema | None: + """ + Возвращает информацию о пользователе по его идентификатору. + + :param user_id: Идентификатор пользователя, для которого нужно получить информацию. + Может быть представлен в виде UUID или строки. + :type user_id: uuid.UUID | str + :returns: Схема данных пользователя, если пользователь найден; None, если пользователь не найден. + :rtype: UserVerifySchema | None + """ + async with self.db.db_session() as session: + query = select(self.model.id, self.model.email).where(self.model.id == user_id) + + result = await session.execute(query) + user = result.mappings().one_or_none() + + if user: + return UserVerifySchema(**user) + + return None + async def store_access_token(self, token: str, user_id: uuid.UUID | str, session_id: str) -> None: """ Сохраняет токен доступа в хранилище (Redis). @@ -106,3 +132,30 @@ class UserManager: """ async with self.redis.get_client() as client: await client.set(f"{user_id}:{session_id}", token) + + async def get_access_token(self, user_id: uuid.UUID | str, session_id: str) -> str | None: + """ + Получает токен доступа из кэша по идентификаторам пользователя и сессии. + + :param user_id: Идентификатор пользователя, может быть в формате UUID или строка. + :type user_id: uuid.UUID | str + :param session_id: Идентификатор сессии, строка. + :type session_id: str + :returns: Токен доступа из кэша, если он существует; иначе None. + :rtype: str | None + """ + async with self.redis.get_client() as client: + return await client.get(f"{user_id}:{session_id}") + + async def revoke_access_token(self, user_id: uuid.UUID | str, session_id: str | uuid.UUID | None) -> None: + """ + Отзывает доступный токен доступа пользователя. + + :param user_id: Идентификатор пользователя, которому принадлежит токен. + :type user_id: uuid.UUID | str + :param session_id: Идентификатор сессии, для которой должен быть отозван токен. + Если None, то все сессии пользователя будут отозваны. + :type session_id: str | uuid.UUID | None + """ + async with self.redis.get_client() as client: + await client.delete(f"{user_id}:{session_id}") diff --git a/lkeep/apps/auth/routes.py b/lkeep/apps/auth/routes.py index aef803d..e554843 100644 --- a/lkeep/apps/auth/routes.py +++ b/lkeep/apps/auth/routes.py @@ -6,11 +6,14 @@ https://pressanybutton.ru/category/servis-na-fastapi/ """ +from typing import Annotated + from fastapi import APIRouter, Depends from starlette import status from starlette.responses import JSONResponse -from lkeep.apps.auth.schemas import AuthUser, UserReturnData +from lkeep.apps.auth.depends import get_current_user +from lkeep.apps.auth.schemas import AuthUser, UserReturnData, UserVerifySchema from lkeep.apps.auth.services import UserService auth_router = APIRouter(prefix="/auth", tags=["auth"]) @@ -63,3 +66,35 @@ async def login(user: AuthUser, service: UserService = Depends(UserService)) -> :raises HTTPException: Если учетные данные не верны или произошла другая ошибка при входе. """ return await service.login_user(user=user) + + +@auth_router.get(path="/logout", status_code=status.HTTP_200_OK) +async def logout( + user: Annotated[UserVerifySchema, Depends(get_current_user)], service: UserService = Depends(UserService) +) -> JSONResponse: + """ + Описание функции logout. + + :param user: Текущий авторизованный пользователь. + :type user: UserVerifySchema + :param service: Сервис для управления пользователями. + :type service: UserService + :returns: JSON-ответ, содержащий результат логаута. + :rtype: JSONResponse + """ + return await service.logout_user(user=user) + + +@auth_router.get(path="/get-user", status_code=status.HTTP_200_OK, response_model=UserVerifySchema) +async def get_auth_user(user: Annotated[UserVerifySchema, Depends(get_current_user)]) -> UserVerifySchema: + """ + Возвращает информацию об авторизованном пользователе. + + :param user: Информация о пользователе, полученная с помощью механизма аутентификации. + :type user: UserVerifySchema + :return: Схема данных пользователя, содержащая необходимую информацию для работы системы. + :rtype: UserVerifySchema + + :raises HTTPException 401: Если пользователь не авторизован и попытка получить доступ к защищенному ресурсу. + """ + return user diff --git a/lkeep/apps/auth/schemas.py b/lkeep/apps/auth/schemas.py index 135a3c9..cecafb1 100644 --- a/lkeep/apps/auth/schemas.py +++ b/lkeep/apps/auth/schemas.py @@ -34,6 +34,17 @@ class GetUserByEmail(BaseModel): email: EmailStr +class UserVerifySchema(GetUserByID, GetUserByEmail): + """ + Класс для валидации данных пользователя. + + Данный класс наследует методы из классов GetUserByID и GetUserByEmail, + что позволяет использовать их функциональность для проверки данных пользователя. + """ + + session_id: uuid.UUID | str | None = None + + class AuthUser(GetUserByEmail): """ Класс для регистрации пользователя, наследующий класс GetUserByEmail. diff --git a/lkeep/apps/auth/services.py b/lkeep/apps/auth/services.py index c6ab568..c1f6212 100644 --- a/lkeep/apps/auth/services.py +++ b/lkeep/apps/auth/services.py @@ -13,7 +13,12 @@ from starlette.responses import JSONResponse from lkeep.apps.auth.handlers import AuthHandler from lkeep.apps.auth.managers import UserManager -from lkeep.apps.auth.schemas import AuthUser, CreateUser, UserReturnData +from lkeep.apps.auth.schemas import ( + AuthUser, + CreateUser, + UserReturnData, + UserVerifySchema, +) from lkeep.apps.auth.tasks import send_confirmation_email from lkeep.core.settings import settings @@ -85,7 +90,7 @@ class UserService: """ exist_user = await self.manager.get_user_by_email(email=user.email) - if exist_user is None or not self.handler.verify_password( + if exist_user is None or not await self.handler.verify_password( hashed_password=exist_user.hashed_password, raw_password=user.password ): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Wrong email or password") @@ -103,3 +108,20 @@ class UserService: ) return response + + async def logout_user(self, user: UserVerifySchema) -> JSONResponse: + """ + Отправляет запрос на выход пользователя из системы. + + :param user: Схема, содержащая информацию о пользователе для аутентификации. + :type user: UserVerifySchema + :returns: Ответ сервера с сообщением об успешном выходе пользователя. + :rtype: JSONResponse + :raises Exception: Если произошла ошибка при отмене токена доступа. + """ + await self.manager.revoke_access_token(user_id=user.id, session_id=user.session_id) + + response = JSONResponse(content={"message": "Logged out"}) + response.delete_cookie(key="Authorization") + + return response diff --git a/lkeep/apps/auth/utils.py b/lkeep/apps/auth/utils.py new file mode 100644 index 0000000..c223588 --- /dev/null +++ b/lkeep/apps/auth/utils.py @@ -0,0 +1,27 @@ +""" +Проект: Lkeep +Автор: Иван Ашихмин +Год: 2025 +Специально для проекта "Код на салфетке" +https://pressanybutton.ru/category/servis-na-fastapi/ +""" + +from fastapi import HTTPException +from starlette import status +from starlette.requests import Request + + +async def get_token_from_cookies(request: Request) -> str: + """ + Получает токен из куки запроса. + + :param request: Объект HTTP-запроса. + :type request: Request + :return: Токен из cookies. + :rtype: str + :raises HTTPException: Если в запросе отсутствует cookie с ключом "Authorization". + """ + token = request.cookies.get("Authorization") + if token is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token is missing") + return token diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29