DispelEvilBot/admin/routes/auth.py

169 lines
6.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Вход в админку через Telegram Login Widget."""
from __future__ import annotations
import logging
import secrets
from fastapi import APIRouter, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from admin.security import check_csrf, safe_redirect_target, verify_telegram_auth
from common import __version__
from common.repositories import admin_login_tokens_repo, admins_repo
logger = logging.getLogger(__name__)
router = APIRouter(tags=["auth"])
#: Поля, которые Telegram включает в подпись (остальные в query игнорируем).
TELEGRAM_FIELDS = frozenset({"id", "first_name", "last_name", "username", "photo_url", "auth_date", "hash"})
def _deny(request: Request, message: str, status_code: int = 403) -> HTMLResponse:
return request.app.state.templates.TemplateResponse(
request=request,
name="auth/denied.html",
context={"version": __version__, "message": message},
status_code=status_code,
)
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request, next: str = "/") -> Response:
"""Страница входа с кнопкой Telegram."""
target = safe_redirect_target(next)
if request.session.get("admin_id"):
return RedirectResponse(url=target, status_code=303)
settings = request.app.state.settings
return request.app.state.templates.TemplateResponse(
request=request,
name="auth/login.html",
context={
"version": __version__,
"bot_username": settings.bot_username,
"public_url": settings.admin_public_url.rstrip("/"),
"configured": bool(settings.bot_token and settings.bot_username),
"deeplink": bool(settings.bot_username and settings.admin_public_url),
"deeplink_pending": bool(request.cookies.get("debot_login_token")),
"deeplink_error": request.query_params.get("error") == "deeplink",
"next": target,
},
)
@router.get("/auth/deeplink/start")
async def deeplink_start(request: Request) -> Response:
"""Создать токен и открыть установленный Telegram с deep-link."""
settings = request.app.state.settings
username = settings.bot_username.lstrip("@")
if not username:
return RedirectResponse(url="/login?error=deeplink", status_code=303)
token = await admin_login_tokens_repo.create_token()
response = RedirectResponse(
url=f"https://t.me/{username}?start=login_{token}",
status_code=303,
)
response.set_cookie(
"debot_login_token",
token,
max_age=300,
httponly=True,
samesite="lax",
secure=settings.is_prod,
)
return response
@router.get("/auth/deeplink/status")
async def deeplink_status(request: Request) -> JSONResponse:
"""Проверить, подтвердил ли пользователь вход в боте, и открыть сессию."""
token = request.cookies.get("debot_login_token")
if not token:
return JSONResponse({"status": "none"})
try:
telegram_id = await admin_login_tokens_repo.consume_token(token)
except Exception:
logger.exception("Не удалось проверить deep-link токен")
return JSONResponse({"status": "pending"})
if telegram_id is None:
return JSONResponse({"status": "pending"})
try:
admin = await admins_repo.get_admin(telegram_id)
except Exception:
logger.exception("Ошибка проверки администратора при deep-link входе")
return JSONResponse({"status": "pending"})
if admin is None or not admin["enabled"]:
response = JSONResponse({"status": "denied"})
response.delete_cookie("debot_login_token")
return response
request.session.clear()
request.session["admin_id"] = telegram_id
request.session["admin_username"] = admin["username"] or ""
request.session["csrf"] = secrets.token_urlsafe(32)
try:
await admins_repo.touch_login(telegram_id)
except Exception:
logger.warning("Не удалось обновить last_login_at", exc_info=True)
logger.info("Вход в админку по deep-link: %s", telegram_id)
response = JSONResponse({"status": "ok"})
response.delete_cookie("debot_login_token")
return response
@router.get("/auth/telegram")
async def telegram_callback(request: Request) -> Response:
"""Проверить подпись Telegram, роль в БД и открыть сессию."""
settings = request.app.state.settings
data = {key: value for key, value in request.query_params.items() if key in TELEGRAM_FIELDS}
if not verify_telegram_auth(data, settings.bot_token):
logger.warning("Отклонён Telegram-логин: неверная подпись или устаревший auth_date")
return _deny(request, "Не удалось проверить вход через Telegram. Попробуйте ещё раз.")
try:
telegram_id = int(str(data.get("id")))
except (TypeError, ValueError):
return _deny(request, "Telegram не передал идентификатор пользователя.", status_code=400)
try:
admin = await admins_repo.get_admin(telegram_id)
except Exception:
logger.exception("Ошибка проверки администратора при входе")
return _deny(request, "Не удалось проверить доступ. Попробуйте позже.", status_code=503)
if admin is None or not admin["enabled"]:
logger.info("Отказ во входе: %s нет среди администраторов", telegram_id)
return _deny(request, "У вас нет доступа к админке. Обратитесь к владельцу.")
request.session.clear()
request.session["admin_id"] = telegram_id
request.session["admin_username"] = admin["username"] or data.get("username") or data.get("first_name") or ""
request.session["csrf"] = secrets.token_urlsafe(32)
try:
await admins_repo.touch_login(telegram_id)
except Exception:
logger.warning("Не удалось обновить last_login_at", exc_info=True)
logger.info("Вход в админку: %s", telegram_id)
return RedirectResponse(url=safe_redirect_target(request.query_params.get("next")), status_code=303)
@router.post("/logout")
async def logout(request: Request) -> Response:
"""Выйти: очистить сессию."""
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
request.session.clear()
return RedirectResponse(url="/login", status_code=303)