Compare commits
No commits in common. "40d45e83796031516099ee34343bf198be893db4" and "807dbee647b7ab7b8d6619b23de3c93210ffa3f3" have entirely different histories.
40d45e8379
...
807dbee647
@ -9,7 +9,6 @@ DB_ECHO=True
|
||||
# Системные переменные
|
||||
SECRET_KEY=1234567890abcdefghigklmnopqrstuvwxyz
|
||||
FRONTEND_URL=http://127.0.0.1:8000/api/v1
|
||||
ACCESS_TOKEN_EXPIRE=3600
|
||||
|
||||
# Переменные для почты
|
||||
EMAIL_HOST=smtp.yandex.ru
|
||||
|
@ -49,8 +49,3 @@ repos:
|
||||
args: [ "--fix", "--line-length=120" ]
|
||||
- id: ruff-format
|
||||
args: [ "--line-length=120" ]
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.15.0
|
||||
hooks:
|
||||
- id: mypy
|
||||
|
@ -30,10 +30,6 @@ PostgreSQL, Poetry, Pydantic и других.
|
||||
- **smtplib** — стандартный модуль Python для отправки электронной почты через протокол SMTP.
|
||||
- **jinja2** — современный и гибкий шаблонизатор, который позволяет динамически генерировать HTML и другие текстовые
|
||||
форматы.
|
||||
- **pyJWT** — библиотека для создания, подписи и верификации JSON Web Tokens (JWT). Используется для генерации токенов
|
||||
доступа, проверки их
|
||||
целостности, срока действия и подписи, а также работы с закодированными данными (payload) в соответствии со
|
||||
стандартами JWT.
|
||||
|
||||
## Репозитории
|
||||
|
||||
@ -52,7 +48,6 @@ PostgreSQL, Poetry, Pydantic и других.
|
||||
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/)
|
||||
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/)
|
||||
|
||||
## Установка
|
||||
|
||||
|
@ -1,18 +1,5 @@
|
||||
"""
|
||||
Проект: Lkeep
|
||||
Автор: Иван Ашихмин
|
||||
Год: 2025
|
||||
Специально для проекта "Код на салфетке"
|
||||
https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import uuid
|
||||
|
||||
import jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from lkeep.apps.auth.named_tuples import CreateTokenTuple
|
||||
from lkeep.core.settings import settings
|
||||
|
||||
|
||||
@ -39,34 +26,3 @@ class AuthHandler:
|
||||
:rtype: str
|
||||
"""
|
||||
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)
|
||||
|
@ -6,15 +6,12 @@
|
||||
https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import Depends, HTTPException
|
||||
from sqlalchemy import insert, select, update
|
||||
from sqlalchemy import insert, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from lkeep.apps.auth.schemas import CreateUser, GetUserWithIDAndEmail, UserReturnData
|
||||
from lkeep.apps.auth.schemas import CreateUser, UserReturnData
|
||||
from lkeep.core.core_dependency.db_dependency import DBDependency
|
||||
from lkeep.core.core_dependency.redis_dependency import RedisDependency
|
||||
from lkeep.database.models import User
|
||||
|
||||
|
||||
@ -23,20 +20,15 @@ class UserManager:
|
||||
Класс для управления пользователями.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, db: DBDependency = Depends(DBDependency), redis: RedisDependency = Depends(RedisDependency)
|
||||
) -> None:
|
||||
def __init__(self, db: DBDependency = Depends(DBDependency)) -> None:
|
||||
"""
|
||||
Инициализирует экземпляр класса.
|
||||
|
||||
:param db: Зависимость для базы данных. По умолчанию используется Depends(DBDependency).
|
||||
:param db: Зависимость от базы данных. По умолчанию используется Depends(DBDependency).
|
||||
:type db: DBDependency
|
||||
:param redis: Зависимость для Redis. По умолчанию используется Depends(RedisDependency).
|
||||
:type redis: RedisDependency
|
||||
"""
|
||||
self.db = db
|
||||
self.model = User
|
||||
self.redis = redis
|
||||
|
||||
async def create_user(self, user: CreateUser) -> UserReturnData:
|
||||
"""
|
||||
@ -72,37 +64,3 @@ class UserManager:
|
||||
query = update(self.model).where(self.model.email == email).values(is_verified=True, is_active=True)
|
||||
await session.execute(query)
|
||||
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)
|
||||
|
@ -1,27 +0,0 @@
|
||||
"""
|
||||
Проект: 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
|
@ -8,21 +8,20 @@ https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
|
||||
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.schemas import RegisterUser, UserReturnData
|
||||
from lkeep.apps.auth.services import UserService
|
||||
|
||||
auth_router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@auth_router.post(path="/register", response_model=UserReturnData, status_code=status.HTTP_201_CREATED)
|
||||
async def registration(user: AuthUser, service: UserService = Depends(UserService)) -> UserReturnData:
|
||||
async def registration(user: RegisterUser, service: UserService = Depends(UserService)) -> UserReturnData:
|
||||
"""
|
||||
Регистрация нового пользователя.
|
||||
|
||||
:param user: Данные нового пользователя, который нужно зарегистрировать.
|
||||
:type user: AuthUser
|
||||
:type user: RegisterUser
|
||||
:param service: Сервис для взаимодействия с пользователями.
|
||||
:type service: UserService
|
||||
:returns: Данные зарегистрированного пользователя.
|
||||
@ -39,27 +38,9 @@ async def confirm_registration(token: str, service: UserService = Depends(UserSe
|
||||
|
||||
:param token: Токен подтверждения регистрации, полученный после отправки на электронную почту.
|
||||
:type token: str
|
||||
:param service: Сервис для взаимодействия с пользователями.
|
||||
:raises HTTPException: Если токен недействителен или срок действия истек.
|
||||
:return: Словарь с сообщением о успешной подтверждении электронной почты.
|
||||
:rtype: dict[str, str]
|
||||
"""
|
||||
await service.confirm_user(token=token)
|
||||
|
||||
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)
|
||||
|
@ -34,7 +34,7 @@ class GetUserByEmail(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class AuthUser(GetUserByEmail):
|
||||
class RegisterUser(GetUserByEmail):
|
||||
"""
|
||||
Класс для регистрации пользователя, наследующий класс GetUserByEmail.
|
||||
|
||||
@ -56,21 +56,6 @@ class CreateUser(GetUserByEmail):
|
||||
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):
|
||||
"""
|
||||
Класс для представления данных пользователя, возвращаемых из API.
|
||||
|
@ -8,12 +8,10 @@ https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
|
||||
from fastapi import Depends, HTTPException
|
||||
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.managers import UserManager
|
||||
from lkeep.apps.auth.schemas import AuthUser, CreateUser, UserReturnData
|
||||
from lkeep.apps.auth.schemas import CreateUser, RegisterUser, UserReturnData
|
||||
from lkeep.apps.auth.tasks import send_confirmation_email
|
||||
from lkeep.core.settings import settings
|
||||
|
||||
@ -38,12 +36,12 @@ class UserService:
|
||||
self.handler = handler
|
||||
self.serializer = URLSafeTimedSerializer(secret_key=settings.secret_key.get_secret_value())
|
||||
|
||||
async def register_user(self, user: AuthUser) -> UserReturnData:
|
||||
async def register_user(self, user: RegisterUser) -> UserReturnData:
|
||||
"""
|
||||
Регистрирует нового пользователя в системе.
|
||||
|
||||
:param user: Информация о пользователе, который нужно зарегистрировать.
|
||||
:type user: AuthUser
|
||||
:type user: RegisterUser
|
||||
:returns: Данные о созданном пользователе.
|
||||
:rtype: UserReturnData
|
||||
"""
|
||||
@ -72,34 +70,3 @@ class UserService:
|
||||
raise HTTPException(status_code=400, detail="Неверный или просроченный токен")
|
||||
|
||||
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
|
||||
|
@ -1,55 +0,0 @@
|
||||
"""
|
||||
Проект: 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()
|
@ -117,8 +117,6 @@ class Settings(BaseSettings):
|
||||
:type templates_dir: str
|
||||
:ivar frontend_url: Адрес фронтенд-приложения.
|
||||
:type frontend_url: str
|
||||
:ivar access_token_expire: Срок жизни JWT-токена
|
||||
:type access_token_expire: int
|
||||
"""
|
||||
|
||||
db_settings: DBSettings = DBSettings()
|
||||
@ -127,7 +125,6 @@ class Settings(BaseSettings):
|
||||
secret_key: SecretStr
|
||||
templates_dir: str = "templates"
|
||||
frontend_url: str
|
||||
access_token_expire: int
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf8", extra="ignore")
|
||||
|
||||
|
874
poetry.lock
generated
874
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -22,7 +22,6 @@ dependencies = [
|
||||
"celery (>=5.4.0,<6.0.0)",
|
||||
"redis (>=5.2.1,<6.0.0)",
|
||||
"itsdangerous (>=2.2.0,<3.0.0)",
|
||||
"pyjwt (>=2.10.1,<3.0.0)",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
Loading…
x
Reference in New Issue
Block a user