Compare commits

...

8 Commits

Author SHA1 Message Date
5a299cf47d feat: приложение профиля
Some checks failed
Lint project / lint (push) Has been cancelled
- Добавлен новый маршрут смены почты и связанная с ним бизнес-логика
- Добавлен новый маршрут смены пароля и связанная с ним бизнес логика
- В схеме `AuthUser` изменена аннотация типа с `str` на `StringConstraints` с учётом минимальной и максимальной длинны пароля
- В `alembic.ini` исправлен путь до директории с миграциями после последнего обновления архитектуры под `Poetry 2.1.3`
- Добавлен `docker-compose.dev.yaml` для запуска БД и Redis в окружении для разработки
- Добавлен `Makefile` с описанием основных команд
- Обновлён `README.md`
2025-07-23 02:03:16 +04:00
170492a5c2 chore: обновление Poetry до актуальной версии (2.1.3) и правка структуры проекта.
Some checks failed
Lint project / lint (push) Has been cancelled
2025-07-18 16:57:50 +04:00
ae14c51b0e chore: обновление Poetry до актуальной версии (2.1.3) и правка структуры проекта.
Some checks failed
Lint project / lint (push) Has been cancelled
2025-07-18 16:54:59 +04:00
78cc7e3f54 feat: добавлены новые функции и схемы в модуль auth
All checks were successful
Lint project / lint (push) Successful in 1m41s
- Добавлен класс UserVerifySchema для валидации данных пользователя, наследующий GetUserByID и GetUserByEmail.
- Обновлена модель AuthUser для регистрации пользователя.
- Добавлены роуты logout и get-user для работы с аутентификацией.
- Создан новый модуль utils с функцией get_token_from_cookies для извлечения токена из куки.
- Добавлена логика для выхода пользователя и получения информации об авторизованном пользователе в сервисах и роутах.
- Обновлены схемы и зависимости в модуле handlers и managers для работы с аутентификацией.
2025-04-29 22:51:00 +04:00
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
51 changed files with 1298 additions and 549 deletions

View File

@ -9,6 +9,7 @@ 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

View File

@ -36,7 +36,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install poetry==2.0.0
pip install poetry==2.1.3
poetry install
poetry run pre-commit install

View File

@ -29,7 +29,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install poetry==2.0.0
pip install poetry==2.1.3
poetry install
poetry run pre-commit install

View File

@ -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

27
Makefile Normal file
View File

@ -0,0 +1,27 @@
.PHONY: install create_migration_dev install_dev lint migrate run_dev run_prod
install:
poetry install
install_dev: install
poetry run pre-commit install
run_dev:
poetry run app
run_prod: install migrate
poetry run app
run_all:
docker compose up -d
$(MAKE) run_prod
migrate:
poetry run alembic upgrade head
create_migration_dev:
@read -p "Введите описание ревизии: " msg; \
poetry run alembic revision --autogenerate -m "$$msg"
lint:
poetry run pre-commit run --all

View File

@ -1,5 +1,10 @@
# Napkin Tools: Lkeep (Links Keeper)
![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/proDreams/lkeep/lint.yaml)
[![Код на салфетке](https://img.shields.io/badge/Telegram-Код_на_салфетке-blue)](https://t.me/press_any_button)
[![Заметки на салфетке](https://img.shields.io/badge/Telegram-Заметки_на_салфетке-blue)](https://t.me/writeanynotes)
[![Кот на салфетке чат](https://img.shields.io/badge/Telegram-Кот_на_салфеткеат-blue)](https://t.me/+Li2vbxfWo0Q4ZDk6)
Lkeep — сервис сокращения ссылок, написанный на Python с использованием современных технологий, таких как FastAPI,
PostgreSQL, Poetry, Pydantic и других.
@ -30,6 +35,10 @@ PostgreSQL, Poetry, Pydantic и других.
- **smtplib** — стандартный модуль Python для отправки электронной почты через протокол SMTP.
- **jinja2** — современный и гибкий шаблонизатор, который позволяет динамически генерировать HTML и другие текстовые
форматы.
- **pyJWT** — библиотека для создания, подписи и верификации JSON Web Tokens (JWT). Используется для генерации токенов
доступа, проверки их
целостности, срока действия и подписи, а также работы с закодированными данными (payload) в соответствии со
стандартами JWT.
## Репозитории
@ -48,6 +57,9 @@ 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/)
9. [FastAPI 9. Logout и проверка авторизации](https://pressanybutton.ru/post/servis-na-fastapi/fastapi-9-logout-i-proverka-avtorizacii/)
10. [FastAPI 10. Изменение данных пользователя]()
## Установка
@ -92,7 +104,14 @@ PostgreSQL, Poetry, Pydantic и других.
Затем откройте файл `.env` и заполните его значениями, соответствующими вашей системе (например, настройки
подключения к базе данных PostgreSQL).
4. **Запустите приложение:**
4. **Запустите БД и Redis**
Для запуска контейнера с PostgreSQL и Redis используйте команду в терминале:
```bash
docker compose up -d
```
5. **Запустите приложение:**
Для запуска сервера в режиме разработки используйте команду с Poetry:
```bash

View File

@ -3,7 +3,7 @@
[alembic]
# path to migration scripts.
# Use forward slashes (/) also on windows to provide an os agnostic path
script_location = lkeep/database/alembic
script_location = src/lkeep/database/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time

39
docker-compose.dev.yaml Normal file
View File

@ -0,0 +1,39 @@
services:
lkeep_db:
image: postgres:17.5-alpine
container_name: lkeep_db
restart: always
environment:
- TZ=Europe/Moscow
- POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=${DB_NAME}
- PGDATA=/var/lib/postgresql/data/pgdata
ports:
- "5432:5432"
volumes:
- lkeep_db:/var/lib/postgresql/data
- /etc/localtime:/etc/localtime:ro
healthcheck:
test: [ "CMD-SHELL", "sh -c 'pg_isready -U ${DB_USER} -d ${DB_NAME}'" ]
interval: 10s
timeout: 3s
retries: 3
lkeep_redis:
image: redis:7.4-alpine
container_name: lkeep_redis
restart: always
ports:
- "6379:6379"
volumes:
- lkeep_redis:/data
healthcheck:
test: [ "CMD-SHELL", "redis-cli", "ping" ]
interval: 10s
timeout: 5s
retries: 3
volumes:
lkeep_db:
lkeep_redis:

View File

@ -1,28 +0,0 @@
from passlib.context import CryptContext
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)

View File

@ -1,66 +0,0 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""
from fastapi import Depends, HTTPException
from sqlalchemy import insert, update
from sqlalchemy.exc import IntegrityError
from lkeep.apps.auth.schemas import CreateUser, UserReturnData
from lkeep.core.core_dependency.db_dependency import DBDependency
from lkeep.database.models import User
class UserManager:
"""
Класс для управления пользователями.
"""
def __init__(self, db: DBDependency = Depends(DBDependency)) -> None:
"""
Инициализирует экземпляр класса.
:param db: Зависимость от базы данных. По умолчанию используется Depends(DBDependency).
:type db: DBDependency
"""
self.db = db
self.model = User
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()

View File

@ -1,46 +0,0 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""
from fastapi import APIRouter, Depends
from starlette import status
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: RegisterUser, service: UserService = Depends(UserService)) -> UserReturnData:
"""
Регистрация нового пользователя.
:param user: Данные нового пользователя, который нужно зарегистрировать.
:type user: RegisterUser
: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
:raises HTTPException: Если токен недействителен или срок действия истек.
:return: Словарь с сообщением о успешной подтверждении электронной почты.
:rtype: dict[str, str]
"""
await service.confirm_user(token=token)
return {"message": "Электронная почта подтверждена"}

874
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -22,14 +22,22 @@ 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]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.poetry]
packages = [{include = "lkeep", from = "src"}]
[project.scripts]
app = "lkeep.main:start"
[tool.poetry.group.dev.dependencies]
black = "^25.1.0"
[tool.ruff]
line-length = 120
indent-width = 4

View File

@ -9,7 +9,9 @@ https://pressanybutton.ru/category/servis-na-fastapi/
from fastapi import APIRouter
from lkeep.apps.auth.routes import auth_router
from lkeep.apps.profile.routes import profile_router
apps_router = APIRouter(prefix="/api/v1")
apps_router.include_router(router=auth_router)
apps_router.include_router(router=profile_router)

View 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

View 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")

View 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}")

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

@ -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

View File

@ -8,8 +8,9 @@ https://pressanybutton.ru/category/servis-na-fastapi/
import datetime
import uuid
from typing import Annotated
from pydantic import BaseModel, EmailStr
from pydantic import BaseModel, EmailStr, StringConstraints
class GetUserByID(BaseModel):
@ -34,7 +35,18 @@ class GetUserByEmail(BaseModel):
email: EmailStr
class RegisterUser(GetUserByEmail):
class UserVerifySchema(GetUserByID, GetUserByEmail):
"""
Класс для валидации данных пользователя.
Данный класс наследует методы из классов GetUserByID и GetUserByEmail,
что позволяет использовать их функциональность для проверки данных пользователя.
"""
session_id: uuid.UUID | str | None = None
class AuthUser(GetUserByEmail):
"""
Класс для регистрации пользователя, наследующий класс GetUserByEmail.
@ -42,7 +54,7 @@ class RegisterUser(GetUserByEmail):
:type password: str
"""
password: str
password: Annotated[str, StringConstraints(min_length=8, max_length=128)]
class CreateUser(GetUserByEmail):
@ -56,6 +68,21 @@ 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.

View File

@ -8,10 +8,17 @@ 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 CreateUser, RegisterUser, 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
@ -36,12 +43,12 @@ class UserService:
self.handler = handler
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: Информация о пользователе, который нужно зарегистрировать.
:type user: RegisterUser
:type user: AuthUser
:returns: Данные о созданном пользователе.
:rtype: UserReturnData
"""
@ -70,3 +77,51 @@ 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 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

View 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

View File

@ -0,0 +1,30 @@
import uuid
from typing import Any
from fastapi import Depends
from sqlalchemy import select, update
from lkeep.core.core_dependency.db_dependency import DBDependency
from lkeep.database.models import User
class ProfileManager:
def __init__(self, db: DBDependency = Depends(DBDependency)) -> None:
self.db = db
self.user_model = User
async def update_user_fields(self, user_id: uuid.UUID | str, **kwargs: Any) -> None:
async with self.db.db_session() as session:
query = update(self.user_model).where(self.user_model.id == user_id).values(**kwargs)
await session.execute(query)
await session.commit()
async def get_user_hashed_password(self, user_id: uuid.UUID | str) -> str:
async with self.db.db_session() as session:
query = select(self.user_model.hashed_password).where(self.user_model.id == user_id)
result = await session.execute(query)
return result.scalar()

View File

@ -0,0 +1,30 @@
from typing import Annotated
from fastapi import APIRouter, Depends
from starlette import status
from starlette.responses import Response
from lkeep.apps.auth.depends import get_current_user
from lkeep.apps.auth.schemas import UserVerifySchema
from lkeep.apps.profile.schemas import ChangeEmailRequest, ChangePasswordRequest
from lkeep.apps.profile.services import ProfileService
profile_router = APIRouter(prefix="/profile", tags=["profile"])
@profile_router.post("/change-email", status_code=status.HTTP_200_OK)
async def change_email(
data: ChangeEmailRequest,
user: Annotated[UserVerifySchema, Depends(get_current_user)],
service: ProfileService = Depends(ProfileService),
) -> None:
return await service.change_email(data=data, user=user)
@profile_router.post("/change-password", status_code=status.HTTP_200_OK)
async def change_password(
data: ChangePasswordRequest,
user: Annotated[UserVerifySchema, Depends(get_current_user)],
service: ProfileService = Depends(ProfileService),
) -> Response:
return await service.change_password(data=data, user=user)

View File

@ -0,0 +1,12 @@
from typing import Annotated
from pydantic import BaseModel, EmailStr, StringConstraints
class ChangeEmailRequest(BaseModel):
new_email: EmailStr
class ChangePasswordRequest(BaseModel):
old_password: Annotated[str, StringConstraints(min_length=8, max_length=128)]
new_password: Annotated[str, StringConstraints(min_length=8, max_length=128)]

View File

@ -0,0 +1,30 @@
from fastapi import Depends
from starlette.responses import JSONResponse
from lkeep.apps.auth.handlers import AuthHandler
from lkeep.apps.auth.schemas import UserVerifySchema
from lkeep.apps.profile.managers import ProfileManager
from lkeep.apps.profile.schemas import ChangeEmailRequest, ChangePasswordRequest
class ProfileService:
def __init__(
self,
manager: ProfileManager = Depends(ProfileManager),
handler: AuthHandler = Depends(AuthHandler),
) -> None:
self.manager = manager
self.handler = handler
async def change_email(self, data: ChangeEmailRequest, user: UserVerifySchema) -> None:
return await self.manager.update_user_fields(user_id=user.id, email=data.new_email)
async def change_password(self, data: ChangePasswordRequest, user: UserVerifySchema) -> None | JSONResponse:
current_password_hash = await self.manager.get_user_hashed_password(user_id=user.id)
if await self.handler.verify_password(raw_password=data.old_password, hashed_password=current_password_hash):
hashed_password = await self.handler.get_password_hash(password=data.new_password)
await self.manager.update_user_fields(user_id=user.id, hashed_password=hashed_password)
return None
return JSONResponse({"error": "Invalid password"}, status_code=401)

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

View File

View File

@ -8,6 +8,7 @@ https://pressanybutton.ru/category/servis-na-fastapi/
import uvicorn
from fastapi import FastAPI
from starlette.middleware.cors import CORSMiddleware
from lkeep.apps import apps_router
@ -15,6 +16,14 @@ app = FastAPI()
app.include_router(router=apps_router)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://127.0.0.1:3000"], # TODO: ЗАМЕНИТЬ ПОТОМ НА ДОМЕН
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
def start():
uvicorn.run(app="lkeep.main:app", reload=True)

0
tests/__init__.py Normal file
View File