v0.1 - макет интерфейса, аутентификация через логин, аккаунт админа, создание партии в 2 этапа, базовые настройки профиля и группы, переключение между группами, статистика
This commit is contained in:
@@ -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
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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),
|
||||
)
|
||||
Reference in New Issue
Block a user