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:
@@ -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
|
||||
@@ -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))
|
||||
)
|
||||
Reference in New Issue
Block a user