v0.1 - макет интерфейса, аутентификация через логин, аккаунт админа, создание партии в 2 этапа, базовые настройки профиля и группы, переключение между группами, статистика

This commit is contained in:
2026-06-16 17:41:06 +03:00
parent 6ab74f01aa
commit 56b5d09a4d
123 changed files with 13572 additions and 21 deletions
View File
+43
View File
@@ -0,0 +1,43 @@
"""FastAPI-зависимости аутентификации и авторизации."""
from __future__ import annotations
import jwt
from fastapi import Depends, Request
from sqlmodel import Session
from app.core import security
from app.core.errors import AuthError, ForbiddenError
from app.db.session import get_session
from app.models import User
def get_current_user(
request: Request, session: Session = Depends(get_session)
) -> User:
token = request.cookies.get(security.USER_COOKIE)
if not token:
raise AuthError("Требуется вход.")
try:
payload = security.decode_token(token, security.AUDIENCE_USER)
except jwt.PyJWTError as exc: # noqa: F841
raise AuthError("Сессия недействительна.")
user = session.get(User, int(payload["sub"]))
if user is None or not user.is_active:
raise AuthError("Сессия недействительна.")
return user
def get_current_admin(
request: Request, session: Session = Depends(get_session)
) -> User:
token = request.cookies.get(security.ADMIN_COOKIE)
if not token:
raise AuthError("Требуется вход администратора.")
try:
payload = security.decode_token(token, security.AUDIENCE_ADMIN)
except jwt.PyJWTError:
raise AuthError("Сессия администратора недействительна.")
user = session.get(User, int(payload["sub"]))
if user is None or user.role != "admin" or not user.is_active:
raise ForbiddenError("Доступ только для администратора.")
return user
+23
View File
@@ -0,0 +1,23 @@
"""Dev-провайдер: вход без секрета по нику/идентификатору (только не-production)."""
from __future__ import annotations
from typing import Any
from app.auth.provider import AuthProvider, ExternalIdentity
from app.core.errors import ValidationError
class DevStubProvider(AuthProvider):
name = "stub"
def authenticate(self, payload: dict[str, Any]) -> ExternalIdentity:
nickname = (payload.get("nickname") or "").strip()
external_id = str(payload.get("external_id") or nickname).strip()
if not external_id:
raise ValidationError("Укажите никнейм для входа.")
return ExternalIdentity(
provider=self.name,
external_id=external_id,
suggested_nickname=nickname or external_id,
raw=dict(payload),
)
+36
View File
@@ -0,0 +1,36 @@
"""Общий вход: внешняя личность → пользователь → сессия.
Прод-безопасный модуль (без импортов dev-провайдера). Используется и Telegram-входом,
и dev-входом.
"""
from __future__ import annotations
from fastapi import Request, Response
from sqlmodel import Session
from app.auth.provider import ExternalIdentity
from app.core import security
from app.core.errors import ForbiddenError
from app.models import User
from app.services import audit_service, user_service
def login_with_identity(
session: Session, response: Response, request: Request, identity: ExternalIdentity
) -> User:
user = user_service.get_or_create_from_identity(session, identity)
if not user.is_active:
raise ForbiddenError("Аккаунт отключён администратором.", code="ACCOUNT_DISABLED")
security.set_user_session(response, user.id, identity.provider) # type: ignore[arg-type]
audit_service.record(
session,
actor_id=user.id,
action="login",
entity_type="user",
entity_id=user.id,
payload={"provider": identity.provider},
ip=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
)
session.commit()
return user
+26
View File
@@ -0,0 +1,26 @@
"""Абстракция провайдера аутентификации (pluggable)."""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any
@dataclass
class ExternalIdentity:
"""Нормализованная личность от внешнего провайдера."""
provider: str
external_id: str
suggested_nickname: str | None = None
telegram_id: int | None = None
raw: dict[str, Any] = field(default_factory=dict)
class AuthProvider(ABC):
name: str
@abstractmethod
def authenticate(self, payload: dict[str, Any]) -> ExternalIdentity:
"""Проверяет вход и возвращает внешнюю личность. Бросает AppError при ошибке."""
raise NotImplementedError
+15
View File
@@ -0,0 +1,15 @@
"""Доступные методы входа по окружению.
Telegram — всегда; stub (вход по нику) — только в development (test/prod = только TG).
Здесь НЕТ импорта dev-провайдера, чтобы прод-образ не зависел от dev-кода.
"""
from __future__ import annotations
from app.core.config import settings
def enabled_methods() -> list[str]:
methods = ["telegram"]
if settings.is_development:
methods.append("stub")
return methods
+59
View File
@@ -0,0 +1,59 @@
"""Telegram Login Widget провайдер.
Проверяет подпись данных виджета (HMAC-SHA256 ключом SHA256(BOT_TOKEN)) и свежесть
auth_date. Нужны TELEGRAM_BOT_TOKEN (+ TELEGRAM_BOT_USERNAME для виджета на фронте).
Доступен и в dev, и в prod (в prod — единственный метод входа).
"""
from __future__ import annotations
import hashlib
import hmac
import time
from typing import Any
from app.auth.provider import AuthProvider, ExternalIdentity
from app.core.config import settings
from app.core.errors import AuthError
_MAX_AUTH_AGE_SECONDS = 86400 # сутки
class TelegramProvider(AuthProvider):
name = "telegram"
def authenticate(self, payload: dict[str, Any]) -> ExternalIdentity:
token = settings.telegram_bot_token
if not token:
raise AuthError("Telegram-вход не настроен (нет TELEGRAM_BOT_TOKEN).")
data = {k: v for k, v in payload.items() if k != "hash" and v is not None}
received_hash = payload.get("hash")
if not received_hash:
raise AuthError("Отсутствует подпись Telegram.")
check_string = "\n".join(f"{k}={data[k]}" for k in sorted(data))
secret_key = hashlib.sha256(token.encode("utf-8")).digest()
computed = hmac.new(
secret_key, check_string.encode("utf-8"), hashlib.sha256
).hexdigest()
if not hmac.compare_digest(computed, str(received_hash)):
raise AuthError("Подпись Telegram не прошла проверку.")
auth_date = int(data.get("auth_date", 0))
if auth_date and time.time() - auth_date > _MAX_AUTH_AGE_SECONDS:
raise AuthError("Срок действия данных Telegram истёк.")
tg_id = int(data["id"])
username = data.get("username")
first = data.get("first_name", "")
last = data.get("last_name", "")
suggested = username or (f"{first} {last}".strip()) or str(tg_id)
return ExternalIdentity(
provider=self.name,
external_id=str(tg_id),
suggested_nickname=suggested,
telegram_id=tg_id,
raw=dict(payload),
)