Initial commit: backend scaffold, auth, frontend login

- FastAPI + SQLAlchemy async + Alembic + Postgres backend
- Auth: JWT access + rotating refresh tokens, argon2, roles, audit log
- React 19 + Vite frontend: login page, protected route, auth context
- Docker Compose: postgres, redis, migrate, api, worker (placeholder), frontend/nginx

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-22 15:07:15 +03:00
co-authored by Claude Sonnet 5
commit 2664cb8213
71 changed files with 4999 additions and 0 deletions
View File
+72
View File
@@ -0,0 +1,72 @@
"""Запись событий в журнал аудита."""
from __future__ import annotations
import uuid
from typing import Any
from fastapi import Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models.audit import AuditLog
from app.db.models.user import User
# Ключи, значения которых не должны попасть в журнал даже случайно.
_REDACTED_KEYS = {
"password",
"new_password",
"current_password",
"token",
"access_token",
"refresh_token",
"secret",
"license_key",
"api_key",
}
def _sanitize(payload: dict[str, Any]) -> dict[str, Any]:
"""Журнал аудита читают люди и он живёт годами — секретам там не место."""
return {
key: ("***" if key.lower() in _REDACTED_KEYS else value) for key, value in payload.items()
}
def client_ip(request: Request | None) -> str | None:
if request is None:
return None
# За nginx реальный адрес приходит в X-Forwarded-For; первый элемент — клиент.
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else None
async def record(
session: AsyncSession,
*,
action: str,
user: User | None = None,
actor_label: str | None = None,
entity_type: str | None = None,
entity_id: str | uuid.UUID | None = None,
payload: dict[str, Any] | None = None,
request: Request | None = None,
) -> AuditLog:
"""Добавляет запись в журнал.
Без commit: вызывающий код сам решает границы транзакции, чтобы событие
и изменение состояния фиксировались вместе либо не фиксировались вовсе.
"""
entry = AuditLog(
user_id=user.id if user else None,
actor_label=actor_label or (user.email if user else None),
action=action,
entity_type=entity_type,
entity_id=str(entity_id) if entity_id is not None else None,
payload=_sanitize(payload or {}),
ip=client_ip(request),
user_agent=(request.headers.get("user-agent") if request else None),
)
session.add(entry)
return entry
+166
View File
@@ -0,0 +1,166 @@
"""Логика аутентификации: вход, ротация refresh-токенов, выход."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from fastapi import Request
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.logging import get_logger
from app.core.security import (
create_access_token,
generate_refresh_token,
hash_password,
hash_refresh_token,
password_needs_rehash,
refresh_token_expiry,
verify_password,
)
from app.db.models.audit import AuditAction
from app.db.models.user import RefreshToken, User
from app.schemas.auth import TokenPair
from app.services import audit
log = get_logger(__name__)
class AuthError(Exception):
"""Вход или обновление токена отклонены."""
async def authenticate(
session: AsyncSession, *, email: str, password: str, request: Request | None = None
) -> User:
"""Проверяет учётные данные.
Все отказы — с одинаковым текстом: различающиеся сообщения позволили бы
перебором выяснить, какие адреса зарегистрированы.
"""
normalized = email.strip().lower()
user = await session.scalar(select(User).where(User.email == normalized))
if user is None or not verify_password(password, user.password_hash):
await audit.record(
session,
action=AuditAction.LOGIN_FAILED,
user=user,
actor_label=normalized,
payload={"reason": "bad_credentials"},
request=request,
)
await session.commit()
raise AuthError("Неверный email или пароль")
if not user.is_active:
await audit.record(
session,
action=AuditAction.LOGIN_FAILED,
user=user,
payload={"reason": "inactive"},
request=request,
)
await session.commit()
raise AuthError("Учётная запись отключена")
# Параметры argon2 со временем ужесточаются — обновляем хеш на живом пароле.
if password_needs_rehash(user.password_hash):
user.password_hash = hash_password(password)
user.last_login_at = datetime.now(UTC)
return user
async def issue_token_pair(
session: AsyncSession,
user: User,
*,
request: Request | None = None,
replaces: RefreshToken | None = None,
) -> TokenPair:
"""Выдаёт пару токенов и сохраняет хеш refresh-токена."""
raw_refresh, token_hash = generate_refresh_token()
stored = RefreshToken(
user_id=user.id,
token_hash=token_hash,
expires_at=refresh_token_expiry(),
user_agent=(request.headers.get("user-agent") if request else None),
ip=audit.client_ip(request),
)
session.add(stored)
await session.flush() # нужен stored.id для ссылки replaced_by_id
if replaces is not None:
replaces.revoked_at = datetime.now(UTC)
replaces.replaced_by_id = stored.id
return TokenPair(
access_token=create_access_token(user.id, user.role.value),
refresh_token=raw_refresh,
expires_in=settings.access_token_expire_minutes * 60,
)
async def rotate_refresh_token(
session: AsyncSession, *, raw_token: str, request: Request | None = None
) -> tuple[User, TokenPair]:
"""Обменивает refresh-токен на новую пару, отзывая предъявленный.
Если предъявлен уже отозванный токен, это признак кражи: отзываем всю
цепочку сессий пользователя и требуем полноценного входа.
"""
token_hash = hash_refresh_token(raw_token)
stored = await session.scalar(select(RefreshToken).where(RefreshToken.token_hash == token_hash))
if stored is None:
raise AuthError("Некорректный refresh-токен")
if stored.revoked_at is not None:
await revoke_all_for_user(session, stored.user_id)
await audit.record(
session,
action=AuditAction.TOKEN_REUSE_DETECTED,
entity_type="refresh_token",
entity_id=stored.id,
payload={"user_id": str(stored.user_id)},
request=request,
)
await session.commit()
log.warning("refresh_token_reuse_detected", user_id=str(stored.user_id))
raise AuthError("Сессия отозвана, требуется повторный вход")
if stored.expires_at <= datetime.now(UTC):
raise AuthError("Срок действия refresh-токена истёк")
user = await session.get(User, stored.user_id)
if user is None or not user.is_active:
raise AuthError("Учётная запись недоступна")
pair = await issue_token_pair(session, user, request=request, replaces=stored)
await audit.record(
session, action=AuditAction.TOKEN_REFRESH, user=user, request=request
)
return user, pair
async def revoke_refresh_token(session: AsyncSession, *, raw_token: str) -> None:
"""Выход. Отсутствующий или уже отозванный токен ошибкой не считается."""
token_hash = hash_refresh_token(raw_token)
await session.execute(
update(RefreshToken)
.where(RefreshToken.token_hash == token_hash, RefreshToken.revoked_at.is_(None))
.values(revoked_at=datetime.now(UTC))
)
async def revoke_all_for_user(session: AsyncSession, user_id: uuid.UUID) -> None:
"""Отзывает все активные refresh-токены пользователя (выход со всех устройств)."""
await session.execute(
update(RefreshToken)
.where(RefreshToken.user_id == user_id, RefreshToken.revoked_at.is_(None))
.values(revoked_at=datetime.now(UTC))
)