Compare commits
20 Commits
c6e82f292d
...
main
Author | SHA1 | Date | |
---|---|---|---|
78cc7e3f54 | |||
40d45e8379 | |||
28acd7d04d | |||
e2d0669064 | |||
c33e898218 | |||
807dbee647 | |||
5a2491ec91 | |||
7cfb2e734a | |||
217af1cd06 | |||
84446d44ce | |||
d3b2f4d71a | |||
aec3f41f4f | |||
c9de61e535 | |||
e421b6c0e1 | |||
c0ee89f175 | |||
623e9ba325 | |||
67f980d162 | |||
b71f6d2a81 | |||
2520d49a2b | |||
b10286773a |
17
.env.example
17
.env.example
@ -1,6 +1,23 @@
|
||||
# Переменные для базы данных
|
||||
DB_NAME=db_name
|
||||
DB_USER=db_user
|
||||
DB_PASSWORD=db_password
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
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
|
||||
EMAIL_PORT=465
|
||||
EMAIL_USERNAME=info@yandex.ru
|
||||
EMAIL_PASSWORD=12345
|
||||
|
||||
# Переменные для Redis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PORT=6379
|
||||
REDIS_DB=0
|
||||
|
@ -49,3 +49,8 @@ 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
|
||||
|
24
README.md
24
README.md
@ -16,6 +16,24 @@ PostgreSQL, Poetry, Pydantic и других.
|
||||
- **SQLAlchemy** — ORM для работы с базой данных.
|
||||
- **Poetry** — инструмент для управления зависимостями и виртуальными окружениями.
|
||||
- **Pydantic** — для валидации данных и работы с моделями.
|
||||
- **pre-commit** — инструмент для автоматической проверки кода перед коммитом.
|
||||
- **CI Workflow** — автоматизация тестирования приложения.
|
||||
- **uvicorn** — высокопроизводительный ASGI-сервер для обработки HTTP-запросов.
|
||||
- **pydantic-settings** — библиотека для работы с конфигурациями и переменными окружения с использованием Pydantic.
|
||||
- **passlib** — библиотека для безопасного хеширования паролей и других данных.
|
||||
- **celery** — распределённая система для выполнения фоновых задач и управления очередями, позволяющая выполнять задачи
|
||||
асинхронно.
|
||||
- **redis** — высокопроизводительное in-memory хранилище, используемое для кэширования данных и как брокер сообщений для
|
||||
Celery.
|
||||
- **itsdangerous** — библиотека для безопасного создания и проверки подписанных данных, что помогает защитить токены и
|
||||
другую чувствительную информацию.
|
||||
- **smtplib** — стандартный модуль Python для отправки электронной почты через протокол SMTP.
|
||||
- **jinja2** — современный и гибкий шаблонизатор, который позволяет динамически генерировать HTML и другие текстовые
|
||||
форматы.
|
||||
- **pyJWT** — библиотека для создания, подписи и верификации JSON Web Tokens (JWT). Используется для генерации токенов
|
||||
доступа, проверки их
|
||||
целостности, срока действия и подписи, а также работы с закодированными данными (payload) в соответствии со
|
||||
стандартами JWT.
|
||||
|
||||
## Репозитории
|
||||
|
||||
@ -29,6 +47,12 @@ PostgreSQL, Poetry, Pydantic и других.
|
||||
|
||||
1. [FastAPI 1. Инициализация проекта](https://pressanybutton.ru/post/servis-na-fastapi/fastapi-1-inicializaciya-proekta/)
|
||||
2. [FastAPI 2. Подготовка проекта](https://pressanybutton.ru/post/servis-na-fastapi/fastapi-2-podgotovka-proekta/)
|
||||
3. [FastAPI 3. Подключение к SQLAlchemy и генератор сессий](https://pressanybutton.ru/post/servis-na-fastapi/fastapi-3-podklyuchenie-k-sqlalchemy-i-generator-s/)
|
||||
4. [FastAPI 4. Модель пользователя, миксины и Alembic](https://pressanybutton.ru/post/servis-na-fastapi/fastapi-4-model-polzovatelya-i-alembic/)
|
||||
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/)
|
||||
|
||||
## Установка
|
||||
|
||||
|
15
lkeep/apps/__init__.py
Normal file
15
lkeep/apps/__init__.py
Normal file
@ -0,0 +1,15 @@
|
||||
"""
|
||||
Проект: Lkeep
|
||||
Автор: Иван Ашихмин
|
||||
Год: 2025
|
||||
Специально для проекта "Код на салфетке"
|
||||
https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from lkeep.apps.auth.routes import auth_router
|
||||
|
||||
apps_router = APIRouter(prefix="/api/v1")
|
||||
|
||||
apps_router.include_router(router=auth_router)
|
7
lkeep/apps/auth/__init__.py
Normal file
7
lkeep/apps/auth/__init__.py
Normal file
@ -0,0 +1,7 @@
|
||||
"""
|
||||
Проект: Lkeep
|
||||
Автор: Иван Ашихмин
|
||||
Год: 2025
|
||||
Специально для проекта "Код на салфетке"
|
||||
https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
"""
|
52
lkeep/apps/auth/depends.py
Normal file
52
lkeep/apps/auth/depends.py
Normal file
@ -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
|
93
lkeep/apps/auth/handlers.py
Normal file
93
lkeep/apps/auth/handlers.py
Normal file
@ -0,0 +1,93 @@
|
||||
"""
|
||||
Проект: Lkeep
|
||||
Автор: Иван Ашихмин
|
||||
Год: 2025
|
||||
Специально для проекта "Код на салфетке"
|
||||
https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
|
||||
class AuthHandler:
|
||||
"""
|
||||
Обрабатывает аутентификационные запросы и обеспечивает безопасность пользовательских данных.
|
||||
|
||||
:ivar secret: Секретный ключ, используемый для дополнительной безопасности при генерации хешей.
|
||||
:type secret: str
|
||||
:ivar pwd_context: Контекст для использования bcrypt-алгоритма хеширования паролей.
|
||||
:type pwd_context: CryptContext
|
||||
"""
|
||||
|
||||
secret = settings.secret_key.get_secret_value()
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
async def get_password_hash(self, password: str) -> str:
|
||||
"""
|
||||
Генерирует хэш-значение пароля для безопасного сохранения и сравнения.
|
||||
|
||||
:param password: Пароль пользователя, который нужно зашифровать.
|
||||
:type password: str
|
||||
:returns: Хешированный вариант пароля.
|
||||
: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)
|
||||
|
||||
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")
|
161
lkeep/apps/auth/managers.py
Normal file
161
lkeep/apps/auth/managers.py
Normal file
@ -0,0 +1,161 @@
|
||||
"""
|
||||
Проект: Lkeep
|
||||
Автор: Иван Ашихмин
|
||||
Год: 2025
|
||||
Специально для проекта "Код на салфетке"
|
||||
https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
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,
|
||||
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
|
||||
|
||||
|
||||
class UserManager:
|
||||
"""
|
||||
Класс для управления пользователями.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, db: DBDependency = Depends(DBDependency), redis: RedisDependency = Depends(RedisDependency)
|
||||
) -> None:
|
||||
"""
|
||||
Инициализирует экземпляр класса.
|
||||
|
||||
: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:
|
||||
"""
|
||||
Создает нового пользователя в базе данных.
|
||||
|
||||
:param user: Объект с данными для создания пользователя.
|
||||
:type user: CreateUser
|
||||
:returns: Данные созданного пользователя.
|
||||
:rtype: UserReturnData
|
||||
:raises HTTPException: Если пользователь уже существует.
|
||||
"""
|
||||
async with self.db.db_session() as session:
|
||||
query = insert(self.model).values(**user.model_dump()).returning(self.model)
|
||||
|
||||
try:
|
||||
result = await session.execute(query)
|
||||
except IntegrityError:
|
||||
raise HTTPException(status_code=400, detail="User already exists.")
|
||||
|
||||
await session.commit()
|
||||
|
||||
user_data = result.scalar_one()
|
||||
return UserReturnData(**user_data.__dict__)
|
||||
|
||||
async def confirm_user(self, email: str) -> None:
|
||||
"""
|
||||
Асинхронный метод для подтверждения пользователя по электронной почте.
|
||||
|
||||
:param email: Электронная почта пользователя, которого нужно подтвердить.
|
||||
:type email: str
|
||||
"""
|
||||
async with self.db.db_session() as session:
|
||||
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 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).
|
||||
|
||||
: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)
|
||||
|
||||
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}")
|
27
lkeep/apps/auth/named_tuples.py
Normal file
27
lkeep/apps/auth/named_tuples.py
Normal 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
|
100
lkeep/apps/auth/routes.py
Normal file
100
lkeep/apps/auth/routes.py
Normal file
@ -0,0 +1,100 @@
|
||||
"""
|
||||
Проект: Lkeep
|
||||
Автор: Иван Ашихмин
|
||||
Год: 2025
|
||||
Специально для проекта "Код на салфетке"
|
||||
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.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"])
|
||||
|
||||
|
||||
@auth_router.post(path="/register", response_model=UserReturnData, status_code=status.HTTP_201_CREATED)
|
||||
async def registration(user: AuthUser, service: UserService = Depends(UserService)) -> UserReturnData:
|
||||
"""
|
||||
Регистрация нового пользователя.
|
||||
|
||||
:param user: Данные нового пользователя, который нужно зарегистрировать.
|
||||
:type user: AuthUser
|
||||
:param service: Сервис для взаимодействия с пользователями.
|
||||
:type service: UserService
|
||||
:returns: Данные зарегистрированного пользователя.
|
||||
:rtype: UserReturnData
|
||||
:raises HTTPException 400: Если данные пользователя некорректны.
|
||||
"""
|
||||
return await service.register_user(user=user)
|
||||
|
||||
|
||||
@auth_router.get(path="/register_confirm", status_code=status.HTTP_200_OK)
|
||||
async def confirm_registration(token: str, service: UserService = Depends(UserService)) -> dict[str, str]:
|
||||
"""
|
||||
Подтверждает регистрацию пользователя по ссылке.
|
||||
|
||||
: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)
|
||||
|
||||
|
||||
@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
|
105
lkeep/apps/auth/schemas.py
Normal file
105
lkeep/apps/auth/schemas.py
Normal file
@ -0,0 +1,105 @@
|
||||
"""
|
||||
Проект: Lkeep
|
||||
Автор: Иван Ашихмин
|
||||
Год: 2025
|
||||
Специально для проекта "Код на салфетке"
|
||||
https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
|
||||
class GetUserByID(BaseModel):
|
||||
"""
|
||||
Класс для получения пользователя по его уникальному идентификатору (ID).
|
||||
|
||||
:ivar id: Уникальный идентификатор пользователя, может быть представлен как объект типа uuid.UUID или строкой.
|
||||
:type id: uuid.UUID | str
|
||||
"""
|
||||
|
||||
id: uuid.UUID | str
|
||||
|
||||
|
||||
class GetUserByEmail(BaseModel):
|
||||
"""
|
||||
Класс для поиска пользователя по электронной почте.
|
||||
|
||||
:ivar email: Электронная почта пользователя.
|
||||
:type email: EmailStr
|
||||
"""
|
||||
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class UserVerifySchema(GetUserByID, GetUserByEmail):
|
||||
"""
|
||||
Класс для валидации данных пользователя.
|
||||
|
||||
Данный класс наследует методы из классов GetUserByID и GetUserByEmail,
|
||||
что позволяет использовать их функциональность для проверки данных пользователя.
|
||||
"""
|
||||
|
||||
session_id: uuid.UUID | str | None = None
|
||||
|
||||
|
||||
class AuthUser(GetUserByEmail):
|
||||
"""
|
||||
Класс для регистрации пользователя, наследующий класс GetUserByEmail.
|
||||
|
||||
:ivar password: Пароль пользователя.
|
||||
:type password: str
|
||||
"""
|
||||
|
||||
password: str
|
||||
|
||||
|
||||
class CreateUser(GetUserByEmail):
|
||||
"""
|
||||
Класс для создания пользователя.
|
||||
|
||||
:ivar hashed_password: Хэшированный пароль пользователя.
|
||||
:type 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):
|
||||
"""
|
||||
Класс для представления данных пользователя, возвращаемых из API.
|
||||
|
||||
:ivar is_active: Статус активности пользователя.
|
||||
:type is_active: bool
|
||||
:ivar is_verified: Статус верификации пользователя.
|
||||
:type is_verified: bool
|
||||
:ivar is_superuser: Флаг, указывающий на наличие привилегий суперпользователя.
|
||||
:type is_superuser: bool
|
||||
:ivar created_at: Временная метка создания записи о пользователе.
|
||||
:type created_at: datetime.datetime
|
||||
:ivar updated_at: Временная метка последнего обновления записи о пользователе.
|
||||
:type updated_at: datetime.datetime
|
||||
"""
|
||||
|
||||
is_active: bool
|
||||
is_verified: bool
|
||||
is_superuser: bool
|
||||
created_at: datetime.datetime
|
||||
updated_at: datetime.datetime
|
127
lkeep/apps/auth/services.py
Normal file
127
lkeep/apps/auth/services.py
Normal file
@ -0,0 +1,127 @@
|
||||
"""
|
||||
Проект: Lkeep
|
||||
Автор: Иван Ашихмин
|
||||
Год: 2025
|
||||
Специально для проекта "Код на салфетке"
|
||||
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,
|
||||
UserVerifySchema,
|
||||
)
|
||||
from lkeep.apps.auth.tasks import send_confirmation_email
|
||||
from lkeep.core.settings import settings
|
||||
|
||||
|
||||
class UserService:
|
||||
"""
|
||||
Класс для управления пользователями.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, manager: UserManager = Depends(UserManager), handler: AuthHandler = Depends(AuthHandler)
|
||||
) -> None:
|
||||
"""
|
||||
Инициализирует экземпляр класса, используя зависимости для управления пользователями и авторизации.
|
||||
|
||||
:param manager: Управитель пользователей, отвечающий за CRUD-операции над пользователями.
|
||||
:type manager: UserManager
|
||||
:param handler: Обработчик аутентификации и авторизации, который используется для проверки доступа к ресурсам.
|
||||
:type handler: AuthHandler
|
||||
"""
|
||||
self.manager = manager
|
||||
self.handler = handler
|
||||
self.serializer = URLSafeTimedSerializer(secret_key=settings.secret_key.get_secret_value())
|
||||
|
||||
async def register_user(self, user: AuthUser) -> UserReturnData:
|
||||
"""
|
||||
Регистрирует нового пользователя в системе.
|
||||
|
||||
:param user: Информация о пользователе, который нужно зарегистрировать.
|
||||
:type user: AuthUser
|
||||
:returns: Данные о созданном пользователе.
|
||||
:rtype: UserReturnData
|
||||
"""
|
||||
hashed_password = await self.handler.get_password_hash(user.password)
|
||||
|
||||
new_user = CreateUser(email=user.email, hashed_password=hashed_password)
|
||||
|
||||
user_data = await self.manager.create_user(user=new_user)
|
||||
|
||||
confirmation_token = self.serializer.dumps(user_data.email)
|
||||
send_confirmation_email.delay(to_email=user_data.email, token=confirmation_token)
|
||||
|
||||
return user_data
|
||||
|
||||
async def confirm_user(self, token: str) -> None:
|
||||
"""
|
||||
Подтверждает пользователя по переданному токену.
|
||||
|
||||
:param token: Токен для подтверждения пользователя.
|
||||
:type token: str
|
||||
:raises HTTPException: Если токен неверный или просроченный.
|
||||
"""
|
||||
try:
|
||||
email = self.serializer.loads(token, max_age=3600)
|
||||
except BadSignature:
|
||||
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 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")
|
||||
|
||||
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
|
||||
|
||||
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
|
75
lkeep/apps/auth/tasks.py
Normal file
75
lkeep/apps/auth/tasks.py
Normal file
@ -0,0 +1,75 @@
|
||||
"""
|
||||
Проект: Lkeep
|
||||
Автор: Иван Ашихмин
|
||||
Год: 2025
|
||||
Специально для проекта "Код на салфетке"
|
||||
https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
"""
|
||||
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
|
||||
from celery import shared_task
|
||||
from starlette.templating import Jinja2Templates
|
||||
|
||||
from lkeep.core.settings import settings
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_text_confirmation_email(to_email: str, token: str) -> None:
|
||||
"""
|
||||
Отправляет текстовое подтверждение регистрации по электронной почте.
|
||||
|
||||
:param to_email: Адрес электронной почты получателя подтверждения.
|
||||
:type to_email: str
|
||||
:param token: Токен для подтверждения регистрации.
|
||||
:type token: str
|
||||
"""
|
||||
confirmation_url = f"{settings.frontend_url}/auth/register_confirm?token={token}"
|
||||
|
||||
text = f"""Спасибо за регистрацию!
|
||||
Для подтверждения регистрации перейдите по ссылке: {confirmation_url}
|
||||
"""
|
||||
|
||||
message = EmailMessage()
|
||||
message.set_content(text)
|
||||
message["From"] = settings.email_settings.email_username
|
||||
message["To"] = to_email
|
||||
message["Subject"] = "Подтверждение регистрации"
|
||||
|
||||
with smtplib.SMTP_SSL(host=settings.email_settings.email_host, port=settings.email_settings.email_port) as smtp:
|
||||
smtp.login(
|
||||
user=settings.email_settings.email_username,
|
||||
password=settings.email_settings.email_password.get_secret_value(),
|
||||
)
|
||||
smtp.send_message(msg=message)
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_confirmation_email(to_email: str, token: str) -> None:
|
||||
"""
|
||||
Отправляет подтверждение регистрации по электронной почте.
|
||||
|
||||
:param to_email: Адрес электронной почты получателя сообщения.
|
||||
:type to_email: str
|
||||
:param token: Токен для подтверждения регистрации, передаваемый в URL.
|
||||
:type token: str
|
||||
"""
|
||||
confirmation_url = f"{settings.frontend_url}/auth/register_confirm?token={token}"
|
||||
|
||||
templates = Jinja2Templates(directory=settings.templates_dir)
|
||||
template = templates.get_template(name="confirmation_email.html")
|
||||
html_content = template.render(confirmation_url=confirmation_url)
|
||||
|
||||
message = EmailMessage()
|
||||
message.add_alternative(html_content, subtype="html")
|
||||
message["From"] = settings.email_settings.email_username
|
||||
message["To"] = to_email
|
||||
message["Subject"] = "Подтверждение регистрации"
|
||||
|
||||
with smtplib.SMTP_SSL(host=settings.email_settings.email_host, port=settings.email_settings.email_port) as smtp:
|
||||
smtp.login(
|
||||
user=settings.email_settings.email_username,
|
||||
password=settings.email_settings.email_password.get_secret_value(),
|
||||
)
|
||||
smtp.send_message(msg=message)
|
27
lkeep/apps/auth/utils.py
Normal file
27
lkeep/apps/auth/utils.py
Normal file
@ -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
|
@ -5,3 +5,7 @@
|
||||
Специально для проекта "Код на салфетке"
|
||||
https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
"""
|
||||
|
||||
from .celery_config import celery_app
|
||||
|
||||
__all__ = ["celery_app"]
|
||||
|
15
lkeep/core/celery_config.py
Normal file
15
lkeep/core/celery_config.py
Normal file
@ -0,0 +1,15 @@
|
||||
"""
|
||||
Проект: Lkeep
|
||||
Автор: Иван Ашихмин
|
||||
Год: 2025
|
||||
Специально для проекта "Код на салфетке"
|
||||
https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
"""
|
||||
|
||||
from celery import Celery
|
||||
|
||||
from lkeep.core.settings import settings
|
||||
|
||||
celery_app = Celery(main="lkeep", broker=settings.redis_settings.redis_url, backend=settings.redis_settings.redis_url)
|
||||
|
||||
celery_app.autodiscover_tasks(packages=["lkeep.apps"])
|
@ -1,21 +1,18 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from lkeep.core.settings import settings
|
||||
|
||||
|
||||
class DBDependency:
|
||||
"""
|
||||
Класс для управления зависимостями базы данных, используя SQLAlchemy.
|
||||
"""
|
||||
|
||||
def __init__(self, db_url: str, db_echo: bool) -> None:
|
||||
def __init__(self) -> None:
|
||||
"""
|
||||
Инициализирует экземпляр класса, отвечающего за взаимодействие с асинхронной базой данных.
|
||||
|
||||
:param db_url: URL для подключения к базе данных.
|
||||
:type db_url: str
|
||||
:param db_echo: Флаг, определяющий вывод подробных логов при взаимодействии с базой данных.
|
||||
:type db_echo: bool
|
||||
"""
|
||||
self._engine = create_async_engine(url=db_url, echo=db_echo)
|
||||
self._engine = create_async_engine(url=settings.db_settings.db_url, echo=settings.db_settings.db_echo)
|
||||
self._session_factory = async_sessionmaker(bind=self._engine, expire_on_commit=False, autocommit=False)
|
||||
|
||||
@property
|
||||
|
55
lkeep/core/core_dependency/redis_dependency.py
Normal file
55
lkeep/core/core_dependency/redis_dependency.py
Normal 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()
|
@ -48,15 +48,88 @@ class DBSettings(BaseSettings):
|
||||
return f"postgresql+asyncpg://{self.db_user}:{self.db_password.get_secret_value()}@{self.db_host}:{self.db_port}/{self.db_name}"
|
||||
|
||||
|
||||
class EmailSettings(BaseSettings):
|
||||
"""
|
||||
Настройки для электронной почты.
|
||||
|
||||
:ivar email_host: Адрес SMTP-сервера.
|
||||
:type email_host: str
|
||||
:ivar email_port: Порт, используемый для подключения к SMTP-серверу.
|
||||
:type email_port: int
|
||||
:ivar email_username: Имя пользователя для аутентификации на электронной почтовом сервере.
|
||||
:type email_username: str
|
||||
:ivar email_password: Пароль пользователя, скрытый через `SecretStr` для обеспечения безопасности.
|
||||
:type email_password: SecretStr
|
||||
:model_config: Конфигурация settings, которая указывает на файл окружения и его кодировку.
|
||||
:type model_config: SettingsConfigDict
|
||||
"""
|
||||
|
||||
email_host: str
|
||||
email_port: int
|
||||
email_username: str
|
||||
email_password: SecretStr
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf8", extra="ignore")
|
||||
|
||||
|
||||
class RedisSettings(BaseSettings):
|
||||
"""
|
||||
Класс для настройки соединения с Redis.
|
||||
|
||||
:ivar redis_host: Хост, на котором размещается Redis-сервер.
|
||||
:type redis_host: str
|
||||
:ivar redis_port: Порт, через который происходит соединение с Redis-сервером.
|
||||
:type redis_port: int
|
||||
:ivar redis_db: Номер базы данных для использования в Redis.
|
||||
:type redis_db: int
|
||||
"""
|
||||
|
||||
redis_host: str
|
||||
redis_port: int
|
||||
redis_db: int
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf8", extra="ignore")
|
||||
|
||||
@property
|
||||
def redis_url(self):
|
||||
"""
|
||||
Получает URL для подключения к Redis.
|
||||
|
||||
:returns: Строка с URL для подключения к Redis в формате `redis://<хост>:<порт>/<база данных>`.
|
||||
:rtype: str
|
||||
"""
|
||||
return f"redis://{self.redis_host}:{self.redis_port}/{self.redis_db}"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""
|
||||
Класс Settings используется для хранения настроек приложения.
|
||||
Класс для хранения настроек приложения.
|
||||
|
||||
:ivar db_settings: Экземпляр класса DBSettings, содержащий настройки базы данных.
|
||||
:ivar db_settings: Настройки для работы с базой данных.
|
||||
:type db_settings: DBSettings
|
||||
:ivar email_settings: Настройки для отправки электронной почты.
|
||||
:type email_settings: EmailSettings
|
||||
:ivar redis_settings: Настройки для работы с Redis.
|
||||
:type redis_settings: RedisSettings
|
||||
:ivar secret_key: Секретный ключ приложения.
|
||||
:type secret_key: SecretStr
|
||||
:ivar templates_dir: Путь к директории шаблонов.
|
||||
: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()
|
||||
email_settings: EmailSettings = EmailSettings()
|
||||
redis_settings: RedisSettings = RedisSettings()
|
||||
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")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
@ -9,8 +9,12 @@ https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
|
||||
from lkeep.apps import apps_router
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
app.include_router(router=apps_router)
|
||||
|
||||
|
||||
def start():
|
||||
uvicorn.run(app="lkeep.main:app", reload=True)
|
||||
|
1239
poetry.lock
generated
1239
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -16,6 +16,13 @@ dependencies = [
|
||||
"pydantic-settings (>=2.7.1,<3.0.0)",
|
||||
"alembic (>=1.14.0,<2.0.0)",
|
||||
"ruff (>=0.9.0,<0.10.0)",
|
||||
"pydantic[email] (>=2.10.5,<3.0.0)",
|
||||
"passlib (>=1.7.4,<2.0.0)",
|
||||
"bcrypt (==4.0.1)",
|
||||
"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]
|
||||
@ -24,3 +31,6 @@ build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[project.scripts]
|
||||
app = "lkeep.main:start"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
black = "^25.1.0"
|
||||
|
11
templates/confirmation_email.html
Normal file
11
templates/confirmation_email.html
Normal file
@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<title>Подтверждение регистрации</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Подтверждение регистрации</h1>
|
||||
<p>Для подтверждения регистрации перейдите по ссылке:</p>
|
||||
<a href="{{ confirmation_url }}">Подтвердить регистрацию</a>
|
||||
</body>
|
||||
</html>
|
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
Reference in New Issue
Block a user