Compare commits

..

4 Commits

Author SHA1 Message Date
40d45e8379 docs: добавлены детали о библиотеке pyJWT
All checks were successful
Lint project / lint (push) Successful in 1m42s
- Добавлено описание библиотеки pyJWT в раздел "Установка" и обновлен список статей.
- Обновлен текст в разделе "Приложения", добавлена ссылка на новую статью про маршруты авторизации и JWT.
2025-04-10 12:51:01 +04:00
28acd7d04d refactor: переименован класс и добавлены новые методы в модулях auth
- Переименован класс `RegisterUser` в `AuthUser` для общей аутентификации.
- Добавлена новая схема `GetUserWithIDAndEmail`, которая наследует от `GetUserByID` и `CreateUser`.
- Обновлены импорты и используемые классы в модулях handlers, services и managers для использования нового класса `AuthUser`.
- Добавлен новый файл `named_tuples.py` с определением `CreateTokenTuple`.
- В модуле `handlers.py` добавлены новые методы для аутентификации и создания JWT токенов.
- Обновлены маршруты `/register` и `/login`, чтобы использовать новый класс `AuthUser` и обновленные сервисы.
2025-04-10 12:50:39 +04:00
e2d0669064 feat: добавлен класс для работы с Redis
- Создан новый файл `redis_dependency.py` в каталоге `lkeep/core/core_dependency`.
- Добавлен класс `RedisDependency` для управления соединениями с Redis и взаимодействия с базой данных.
- Реализован метод `_init_pool` для инициализации пула соединений.
- Создан асинхронный контекст менеджер `get_client` для получения клиентской сессии Redis.
2025-04-10 12:48:37 +04:00
c33e898218 feat: добавлены необходимые зависимости и конфигурации
- Добавлена зависимость pyjwt для работы с JWT-токенами.
- Обновлен файл .pre-commit-config.yaml, добавлен хук mypy для проверки типов.
- Внесены изменения в .env.example для поддержки нового параметра ACCESS_TOKEN_EXPIRE.
- Добавлен новый атрибут access_token_expire в класс Settings в lkeep/core/settings.py.
2025-04-10 12:48:10 +04:00
13 changed files with 736 additions and 410 deletions

View File

@ -9,6 +9,7 @@ DB_ECHO=True
# Системные переменные # Системные переменные
SECRET_KEY=1234567890abcdefghigklmnopqrstuvwxyz SECRET_KEY=1234567890abcdefghigklmnopqrstuvwxyz
FRONTEND_URL=http://127.0.0.1:8000/api/v1 FRONTEND_URL=http://127.0.0.1:8000/api/v1
ACCESS_TOKEN_EXPIRE=3600
# Переменные для почты # Переменные для почты
EMAIL_HOST=smtp.yandex.ru EMAIL_HOST=smtp.yandex.ru

View File

@ -49,3 +49,8 @@ repos:
args: [ "--fix", "--line-length=120" ] args: [ "--fix", "--line-length=120" ]
- id: ruff-format - id: ruff-format
args: [ "--line-length=120" ] args: [ "--line-length=120" ]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.15.0
hooks:
- id: mypy

View File

@ -30,6 +30,10 @@ PostgreSQL, Poetry, Pydantic и других.
- **smtplib** — стандартный модуль Python для отправки электронной почты через протокол SMTP. - **smtplib** — стандартный модуль Python для отправки электронной почты через протокол SMTP.
- **jinja2** — современный и гибкий шаблонизатор, который позволяет динамически генерировать HTML и другие текстовые - **jinja2** — современный и гибкий шаблонизатор, который позволяет динамически генерировать HTML и другие текстовые
форматы. форматы.
- **pyJWT** — библиотека для создания, подписи и верификации JSON Web Tokens (JWT). Используется для генерации токенов
доступа, проверки их
целостности, срока действия и подписи, а также работы с закодированными данными (payload) в соответствии со
стандартами JWT.
## Репозитории ## Репозитории
@ -48,6 +52,7 @@ PostgreSQL, Poetry, Pydantic и других.
5. [FastAPI 5. Приложение аутентификации и Pydantic схемы](https://pressanybutton.ru/post/servis-na-fastapi/fastapi-5-prilozhenie-autentifikacii-i-pydantic-sh/) 5. [FastAPI 5. Приложение аутентификации и Pydantic схемы](https://pressanybutton.ru/post/servis-na-fastapi/fastapi-5-prilozhenie-autentifikacii-i-pydantic-sh/)
6. [FastAPI 6. Пользовательский сервис и маршруты регистрации](https://pressanybutton.ru/post/servis-na-fastapi/fastapi-6-polzovatelskij-servis-i-marshruty-regist/) 6. [FastAPI 6. Пользовательский сервис и маршруты регистрации](https://pressanybutton.ru/post/servis-na-fastapi/fastapi-6-polzovatelskij-servis-i-marshruty-regist/)
7. [FastAPI 7. Электронная почта, подтверждение регистрации, Celery и Redis](https://pressanybutton.ru/post/servis-na-fastapi/fastapi-7-elektronnaya-pochta-podtverzhdenie-registracii-celery-i-redis/) 7. [FastAPI 7. Электронная почта, подтверждение регистрации, Celery и Redis](https://pressanybutton.ru/post/servis-na-fastapi/fastapi-7-elektronnaya-pochta-podtverzhdenie-registracii-celery-i-redis/)
8. [FastAPI 8. Маршрут авторизации и JWT](https://pressanybutton.ru/post/servis-na-fastapi/fastapi-8-marshrut-avtorizacii-i-jwt/)
## Установка ## Установка

View File

@ -1,5 +1,18 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""
import datetime
import uuid
import jwt
from passlib.context import CryptContext from passlib.context import CryptContext
from lkeep.apps.auth.named_tuples import CreateTokenTuple
from lkeep.core.settings import settings from lkeep.core.settings import settings
@ -26,3 +39,34 @@ class AuthHandler:
:rtype: str :rtype: str
""" """
return self.pwd_context.hash(password) return self.pwd_context.hash(password)
async def verify_password(self, raw_password: str, hashed_password: str) -> bool:
"""
Проверяет соответствие введенного пароля захэшированному паролю.
:param raw_password: Введенный пользователем пароль.
:type raw_password: str
:param hashed_password: Хэш, с которым сравнивается введенный пароль.
:type hashed_password: str
:returns: Логическое значение, указывающее на успешность проверки.
:rtype: bool
"""
return self.pwd_context.verify(raw_password, hashed_password)
async def create_access_token(self, user_id: uuid.UUID | str) -> CreateTokenTuple:
"""
Создаёт JWT-токен доступа для пользователя.
:param user_id: Уникальный идентификатор пользователя (UUID).
:type user_id: uuid.UUID
:returns: Кортеж, содержащий закодированный JWT-токен и уникальный session_id.
:rtype: CreateTokenTuple
"""
expire = datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=settings.access_token_expire)
session_id = str(uuid.uuid4())
data = {"exp": expire, "session_id": session_id, "user_id": str(user_id)}
encoded_jwt = jwt.encode(payload=data, key=self.secret, algorithm="HS256")
return CreateTokenTuple(encoded_jwt=encoded_jwt, session_id=session_id)

View File

@ -6,12 +6,15 @@
https://pressanybutton.ru/category/servis-na-fastapi/ https://pressanybutton.ru/category/servis-na-fastapi/
""" """
import uuid
from fastapi import Depends, HTTPException from fastapi import Depends, HTTPException
from sqlalchemy import insert, update from sqlalchemy import insert, select, update
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from lkeep.apps.auth.schemas import CreateUser, UserReturnData from lkeep.apps.auth.schemas import CreateUser, GetUserWithIDAndEmail, UserReturnData
from lkeep.core.core_dependency.db_dependency import DBDependency from lkeep.core.core_dependency.db_dependency import DBDependency
from lkeep.core.core_dependency.redis_dependency import RedisDependency
from lkeep.database.models import User from lkeep.database.models import User
@ -20,15 +23,20 @@ class UserManager:
Класс для управления пользователями. Класс для управления пользователями.
""" """
def __init__(self, db: DBDependency = Depends(DBDependency)) -> None: def __init__(
self, db: DBDependency = Depends(DBDependency), redis: RedisDependency = Depends(RedisDependency)
) -> None:
""" """
Инициализирует экземпляр класса. Инициализирует экземпляр класса.
:param db: Зависимость от базы данных. По умолчанию используется Depends(DBDependency). :param db: Зависимость для базы данных. По умолчанию используется Depends(DBDependency).
:type db: DBDependency :type db: DBDependency
:param redis: Зависимость для Redis. По умолчанию используется Depends(RedisDependency).
:type redis: RedisDependency
""" """
self.db = db self.db = db
self.model = User self.model = User
self.redis = redis
async def create_user(self, user: CreateUser) -> UserReturnData: async def create_user(self, user: CreateUser) -> UserReturnData:
""" """
@ -64,3 +72,37 @@ class UserManager:
query = update(self.model).where(self.model.email == email).values(is_verified=True, is_active=True) query = update(self.model).where(self.model.email == email).values(is_verified=True, is_active=True)
await session.execute(query) await session.execute(query)
await session.commit() await session.commit()
async def get_user_by_email(self, email: str) -> GetUserWithIDAndEmail | None:
"""
Возвращает пользователя по указанному адресу электронной почты.
:param email: Адрес электронной почты пользователя для поиска.
:type email: str
:return: Объект пользователя с полями id и email, если пользователь найден; None в противном случае.
:rtype: GetUserWithIDAndEmail | None
"""
async with self.db.db_session() as session:
query = select(self.model.id, self.model.email, self.model.hashed_password).where(self.model.email == email)
result = await session.execute(query)
user = result.mappings().first()
if user:
return GetUserWithIDAndEmail(**user)
return None
async def store_access_token(self, token: str, user_id: uuid.UUID | str, session_id: str) -> None:
"""
Сохраняет токен доступа в хранилище (Redis).
:param token: Токен доступа для сохранения.
:type token: str
:param user_id: Идентификатор пользователя, которому принадлежит токен.
:type user_id: uuid.UUID
:param session_id: Идентификатор сессии, связанной с токеном.
:type session_id: str
"""
async with self.redis.get_client() as client:
await client.set(f"{user_id}:{session_id}", token)

View File

@ -0,0 +1,27 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""
from typing import NamedTuple
class CreateTokenTuple(NamedTuple):
"""
Класс для создания кортежа токенов, содержащего закодированный JWT и идентификатор сессии.
Класс наследует от `NamedTuple` и представляет собой неизменяемый контейнер для хранения двух значений:
- закодированного JSON Web Token (JWT)
- уникального идентификатора сессии.
:ivar encoded_jwt: Закодированный JWT-токен.
:type encoded_jwt: str
:ivar session_id: Уникальный идентификатор сессии.
:type session_id: str
"""
encoded_jwt: str
session_id: str

View File

@ -8,20 +8,21 @@ https://pressanybutton.ru/category/servis-na-fastapi/
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from starlette import status from starlette import status
from starlette.responses import JSONResponse
from lkeep.apps.auth.schemas import RegisterUser, UserReturnData from lkeep.apps.auth.schemas import AuthUser, UserReturnData
from lkeep.apps.auth.services import UserService from lkeep.apps.auth.services import UserService
auth_router = APIRouter(prefix="/auth", tags=["auth"]) auth_router = APIRouter(prefix="/auth", tags=["auth"])
@auth_router.post(path="/register", response_model=UserReturnData, status_code=status.HTTP_201_CREATED) @auth_router.post(path="/register", response_model=UserReturnData, status_code=status.HTTP_201_CREATED)
async def registration(user: RegisterUser, service: UserService = Depends(UserService)) -> UserReturnData: async def registration(user: AuthUser, service: UserService = Depends(UserService)) -> UserReturnData:
""" """
Регистрация нового пользователя. Регистрация нового пользователя.
:param user: Данные нового пользователя, который нужно зарегистрировать. :param user: Данные нового пользователя, который нужно зарегистрировать.
:type user: RegisterUser :type user: AuthUser
:param service: Сервис для взаимодействия с пользователями. :param service: Сервис для взаимодействия с пользователями.
:type service: UserService :type service: UserService
:returns: Данные зарегистрированного пользователя. :returns: Данные зарегистрированного пользователя.
@ -38,9 +39,27 @@ async def confirm_registration(token: str, service: UserService = Depends(UserSe
:param token: Токен подтверждения регистрации, полученный после отправки на электронную почту. :param token: Токен подтверждения регистрации, полученный после отправки на электронную почту.
:type token: str :type token: str
:param service: Сервис для взаимодействия с пользователями.
:raises HTTPException: Если токен недействителен или срок действия истек. :raises HTTPException: Если токен недействителен или срок действия истек.
:return: Словарь с сообщением о успешной подтверждении электронной почты. :return: Словарь с сообщением о успешной подтверждении электронной почты.
:rtype: dict[str, str] :rtype: dict[str, str]
""" """
await service.confirm_user(token=token) await service.confirm_user(token=token)
return {"message": "Электронная почта подтверждена"} return {"message": "Электронная почта подтверждена"}
@auth_router.post(path="/login", status_code=status.HTTP_200_OK)
async def login(user: AuthUser, service: UserService = Depends(UserService)) -> JSONResponse:
"""
Вход пользователя в систему.
:param user: Объект данных пользователя для входа.
:type user: AuthUser
:param service: Сервисный объект для управления пользователями.
:type service: UserService
:returns: JSON-ответ с токеном доступа в Cookies, если вход выполнен успешно.
:rtype: JSONResponse
:raises HTTPException: Если учетные данные не верны или произошла другая ошибка при входе.
"""
return await service.login_user(user=user)

View File

@ -34,7 +34,7 @@ class GetUserByEmail(BaseModel):
email: EmailStr email: EmailStr
class RegisterUser(GetUserByEmail): class AuthUser(GetUserByEmail):
""" """
Класс для регистрации пользователя, наследующий класс GetUserByEmail. Класс для регистрации пользователя, наследующий класс GetUserByEmail.
@ -56,6 +56,21 @@ class CreateUser(GetUserByEmail):
hashed_password: str hashed_password: str
class GetUserWithIDAndEmail(GetUserByID, CreateUser):
"""
Класс для получения пользователя по его ID и email.
:ivar id: Уникальный идентификатор пользователя.
:type id: int
:ivar email: Адрес электронной почты пользователя.
:type email: str
:ivar hashed_password: Хэшированный пароль пользователя.
:type hashed_password: str
"""
pass
class UserReturnData(GetUserByID, GetUserByEmail): class UserReturnData(GetUserByID, GetUserByEmail):
""" """
Класс для представления данных пользователя, возвращаемых из API. Класс для представления данных пользователя, возвращаемых из API.

View File

@ -8,10 +8,12 @@ https://pressanybutton.ru/category/servis-na-fastapi/
from fastapi import Depends, HTTPException from fastapi import Depends, HTTPException
from itsdangerous import BadSignature, URLSafeTimedSerializer from itsdangerous import BadSignature, URLSafeTimedSerializer
from starlette import status
from starlette.responses import JSONResponse
from lkeep.apps.auth.handlers import AuthHandler from lkeep.apps.auth.handlers import AuthHandler
from lkeep.apps.auth.managers import UserManager from lkeep.apps.auth.managers import UserManager
from lkeep.apps.auth.schemas import CreateUser, RegisterUser, UserReturnData from lkeep.apps.auth.schemas import AuthUser, CreateUser, UserReturnData
from lkeep.apps.auth.tasks import send_confirmation_email from lkeep.apps.auth.tasks import send_confirmation_email
from lkeep.core.settings import settings from lkeep.core.settings import settings
@ -36,12 +38,12 @@ class UserService:
self.handler = handler self.handler = handler
self.serializer = URLSafeTimedSerializer(secret_key=settings.secret_key.get_secret_value()) self.serializer = URLSafeTimedSerializer(secret_key=settings.secret_key.get_secret_value())
async def register_user(self, user: RegisterUser) -> UserReturnData: async def register_user(self, user: AuthUser) -> UserReturnData:
""" """
Регистрирует нового пользователя в системе. Регистрирует нового пользователя в системе.
:param user: Информация о пользователе, который нужно зарегистрировать. :param user: Информация о пользователе, который нужно зарегистрировать.
:type user: RegisterUser :type user: AuthUser
:returns: Данные о созданном пользователе. :returns: Данные о созданном пользователе.
:rtype: UserReturnData :rtype: UserReturnData
""" """
@ -70,3 +72,34 @@ class UserService:
raise HTTPException(status_code=400, detail="Неверный или просроченный токен") raise HTTPException(status_code=400, detail="Неверный или просроченный токен")
await self.manager.confirm_user(email=email) await self.manager.confirm_user(email=email)
async def login_user(self, user: AuthUser) -> JSONResponse:
"""
Вход пользователя в систему.
:param user: Объект пользователя с входными данными для аутентификации.
:type user: AuthUser
:returns: Ответ сервера, указывающий на успешность или неудачу входа.
:rtype: JSONResponse
:raises HTTPException: Если предоставленные учетные данные неверны (HTTP 401 Unauthorized).
"""
exist_user = await self.manager.get_user_by_email(email=user.email)
if exist_user is None or not 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")
token, session_id = await self.handler.create_access_token(user_id=exist_user.id)
await self.manager.store_access_token(token=token, user_id=exist_user.id, session_id=session_id)
response = JSONResponse(content={"message": "Вход успешен"})
response.set_cookie(
key="Authorization",
value=token,
httponly=True,
max_age=settings.access_token_expire,
)
return response

View File

@ -0,0 +1,55 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from redis.asyncio import ConnectionPool, Redis
from lkeep.core.settings import settings
class RedisDependency:
"""
Класс, предоставляющий инструменты для работы с Redis через асинхронный клиент.
:ivar _url: URL подключения к Redis серверу.
:type _url: str
:ivar _pool: Пул соединений для управления соединениями с Redis.
:type _pool: ConnectionPool
"""
def __init__(self) -> None:
"""
Инициализирует экземпляр класса для работы с Redis.
"""
self._url = settings.redis_settings.redis_url
self._pool: ConnectionPool = self._init_pool()
def _init_pool(self) -> ConnectionPool:
"""
Инициализирует пул соединений Redis.
:returns: Пул соединений для работы с Redis.
:rtype: ConnectionPool
"""
return ConnectionPool.from_url(url=self._url, encoding="utf-8", decode_responses=True)
@asynccontextmanager
async def get_client(self) -> AsyncGenerator[Redis, None]:
"""
Получает клиентскую сессию Redis для взаимодействия с базой данных.
:returns: Асинхронный генератор клиента Redis.
:rtype: AsyncGenerator[Redis, None]
"""
redis_client = Redis(connection_pool=self._pool)
try:
yield redis_client
finally:
await redis_client.aclose()

View File

@ -117,6 +117,8 @@ class Settings(BaseSettings):
:type templates_dir: str :type templates_dir: str
:ivar frontend_url: Адрес фронтенд-приложения. :ivar frontend_url: Адрес фронтенд-приложения.
:type frontend_url: str :type frontend_url: str
:ivar access_token_expire: Срок жизни JWT-токена
:type access_token_expire: int
""" """
db_settings: DBSettings = DBSettings() db_settings: DBSettings = DBSettings()
@ -125,6 +127,7 @@ class Settings(BaseSettings):
secret_key: SecretStr secret_key: SecretStr
templates_dir: str = "templates" templates_dir: str = "templates"
frontend_url: str frontend_url: str
access_token_expire: int
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf8", extra="ignore") model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf8", extra="ignore")

874
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -22,6 +22,7 @@ dependencies = [
"celery (>=5.4.0,<6.0.0)", "celery (>=5.4.0,<6.0.0)",
"redis (>=5.2.1,<6.0.0)", "redis (>=5.2.1,<6.0.0)",
"itsdangerous (>=2.2.0,<3.0.0)", "itsdangerous (>=2.2.0,<3.0.0)",
"pyjwt (>=2.10.1,<3.0.0)",
] ]
[build-system] [build-system]