From f345844dfed09be9dc85bf03410058289c56c91c Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Mon, 19 Oct 2026 03:53:23 +0500 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=B4=D0=BC=D0=B8=D0=BD=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D0=BB=D0=B0=D1=81=D1=8C=20=D0=B2?= =?UTF-8?q?=D1=85=D0=BE=D0=B4=D0=BE=D0=BC=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=20Telegram?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Подпись Telegram-логина проверяется токеном бота, а доступ есть только у записей в bot.admin_users: роль перечитывается из БД на каждом запросе, поэтому выключение пользователя сразу закрывает доступ. Сессия — подписанная HttpOnly-cookie на 12 часов, все формы получили CSRF-токен, первые администраторы засеваются из ADMIN_IDS при старте. Домены в BotFather уже заданы, но NPM и публикацию не включаем — снаружи админка по-прежнему только на loopback. --- .env.example | 6 ++ DEPLOY.md | 19 ++++ README.md | 8 +- admin/app.py | 35 ++++++- admin/middleware.py | 47 ++++++++++ admin/routes/auth.py | 98 ++++++++++++++++++++ admin/routes/messages.py | 6 +- admin/routes/schedules.py | 26 ++++-- admin/security.py | 69 ++++++++++++++ admin/templates/auth/denied.html | 13 +++ admin/templates/auth/login.html | 28 ++++++ admin/templates/base.html | 7 ++ admin/templates/messages/edit.html | 1 + admin/templates/schedules/form.html | 1 + admin/templates/schedules/list.html | 2 + common/config.py | 10 ++ common/repositories/admins_repo.py | 53 +++++++++++ common/version.py | 2 +- env/prod.env.example | 6 ++ env/test.env.example | 6 ++ requirements-admin.txt | 3 + sql/V17__admin_users.sql | 15 +++ tests/conftest.py | 120 ++++++++++++++++++++++-- tests/test_admin.py | 136 +++++++++++++++------------- tests/test_admin_schedules.py | 94 +++++++++++++------ tests/test_auth.py | 108 ++++++++++++++++++++++ tests/test_version.py | 2 +- 27 files changed, 808 insertions(+), 113 deletions(-) create mode 100644 admin/middleware.py create mode 100644 admin/routes/auth.py create mode 100644 admin/security.py create mode 100644 admin/templates/auth/denied.html create mode 100644 admin/templates/auth/login.html create mode 100644 common/repositories/admins_repo.py create mode 100644 sql/V17__admin_users.sql create mode 100644 tests/test_auth.py diff --git a/.env.example b/.env.example index e9d2abd..579ce5f 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,12 @@ APP_ENV=dev # Токен бота (получается у @BotFather) BOT_TOKEN= +# Username бота без @ (для кнопки Telegram-входа в админку) +BOT_USERNAME= +# Публичный HTTPS-адрес админки (для Telegram Login Widget) +ADMIN_PUBLIC_URL=http://localhost:8000 +# Секрет подписи cookie-сессии: openssl rand -hex 32 (в проде обязателен) +ADMIN_SESSION_SECRET= # Postgres DB_HOST=localhost diff --git a/DEPLOY.md b/DEPLOY.md index 242ba11..c729a7e 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -17,6 +17,8 @@ cd DispelEvilBot cp env/test.env.example env/test.env # заполнить: BOT_TOKEN, DB_PASSWORD, CHAT_ID (основной чат), ALERT_CHAT_ID (опц.), # ADMIN_IDS (опц.), CLICKHOUSE_PASSWORD (аналитика событий) +# плюс для админки: BOT_USERNAME, ADMIN_PUBLIC_URL (HTTPS-домен), +# ADMIN_SESSION_SECRET (openssl rand -hex 32) ``` Порт ClickHouse на хосте (`CLICKHOUSE_HTTP_PORT`) у контуров должен различаться: @@ -100,6 +102,23 @@ curl -s http://127.0.0.1:8101/healthz # prod Локально без Docker: `pip install -r requirements-admin.txt && python -m admin` (по умолчанию `0.0.0.0:8000`, переопределяется `ADMIN_HOST`/`ADMIN_PORT`). +### Вход через Telegram + +Админка закрыта Telegram Login Widget. Что нужно на контуре: + +- `BOT_TOKEN` — токен бота этого контура (им же проверяется подпись `hash`); +- `BOT_USERNAME` — username бота без `@` (виджет рисует кнопку); +- `ADMIN_PUBLIC_URL` — публичный HTTPS-адрес (`https://debot-test.mpakosrv.ru`); +- `ADMIN_SESSION_SECRET` — `openssl rand -hex 32`; +- `ADMIN_IDS` — Telegram-ID администраторов: ими засевается `bot.admin_users` при старте + (`ON CONFLICT DO NOTHING`, существующих не трогает). + +Домен должен быть привязан к боту в `@BotFather` (`/setdomain`), а TLS — настроен на +reverse proxy. Сессия живёт 12 часов (`ADMIN_SESSION_MAX_AGE`), cookie подписана и +`HttpOnly`; в проде ставится флаг `Secure`. Все POST-формы проверяют CSRF-токен. +Роль администратора перечитывается из БД на каждом запросе — отключение пользователя +(`enabled = false`) или удаление записи сразу закрывает доступ. + ## Бэкап событий (забирает домашний сервер) Сырые события живут на VPS ограниченное время: раз в сутки домашний сервер **сам забирает** diff --git a/README.md b/README.md index 5fc516a..609d7de 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,12 @@ APP_ENV=prod python -m bot # prod правка применяется без перезапуска; при недоступной БД бот использует тексты из кода. Предпросмотр использует тот же `render()`, что и бот. +**Вход** — через Telegram Login Widget: подпись `hash` проверяется токеном бота, доступ +есть только у записей в `bot.admin_users` (роль проверяется в БД на каждом запросе). +Первые администраторы засеваются из `ADMIN_IDS` при старте. Сессия — подписанная +HttpOnly-cookie (`ADMIN_SESSION_SECRET`), все формы защищены CSRF. Для входа нужны +`BOT_TOKEN`, `BOT_USERNAME` (без @) и HTTPS-адрес `ADMIN_PUBLIC_URL`. + Раздел «Расписания» — CRUD рассылок (`bot.schedules` + история): время, дни недели, таймзона, шаблон и вкл/выкл. Бот раз в минуту читает активные расписания из БД; если база недоступна — использует расписание из кода (совпадает с сидом `V16`). @@ -127,7 +133,7 @@ make backup E=test # на домашнем сервере: ./scripts/clickho ## Версионирование -- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.4.4`. +- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.4.5`. - Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`). - Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3). - Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`. diff --git a/admin/app.py b/admin/app.py index eebbf1e..8e94d5c 100644 --- a/admin/app.py +++ b/admin/app.py @@ -2,22 +2,29 @@ from __future__ import annotations +import logging +import secrets from collections.abc import AsyncIterator from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI from fastapi.templating import Jinja2Templates +from starlette.middleware.sessions import SessionMiddleware -from admin.routes import health, messages, pages, schedules +from admin.middleware import AdminAuthMiddleware +from admin.routes import auth, health, messages, pages, schedules from common import __version__ from common.config import AdminSettings, get_admin_settings from common.db.pool import close_pool, init_pool from common.logging_config import setup_logging +from common.repositories import admins_repo BASE_DIR = Path(__file__).resolve().parent TEMPLATES_DIR = BASE_DIR / "templates" +logger = logging.getLogger(__name__) + def create_app(settings: AdminSettings | None = None, *, init_db: bool = True) -> FastAPI: """Собрать приложение: настройки, шаблоны, роутеры. @@ -32,6 +39,15 @@ def create_app(settings: AdminSettings | None = None, *, init_db: bool = True) - async def lifespan(app: FastAPI) -> AsyncIterator[None]: if init_db: await init_pool(app.state.settings) + try: + inserted = await admins_repo.ensure_admins(app.state.settings.admin_ids) + if inserted: + logger.info("Добавлены администраторы из ADMIN_IDS: %s", inserted) + except Exception: + logger.warning( + "Не удалось засеять администраторов (применены ли миграции?)", + exc_info=True, + ) try: yield finally: @@ -50,7 +66,24 @@ def create_app(settings: AdminSettings | None = None, *, init_db: bool = True) - app.state.settings = settings app.state.templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) + # Порядок важен: SessionMiddleware добавляется последним и оборачивает auth, + # поэтому request.session доступен в проверке администратора. + app.add_middleware(AdminAuthMiddleware) + session_secret = settings.admin_session_secret + if not session_secret: + session_secret = secrets.token_urlsafe(32) + logger.warning("ADMIN_SESSION_SECRET не задан — сессии слетят при перезапуске") + app.add_middleware( + SessionMiddleware, + secret_key=session_secret, + session_cookie="debot_admin", + max_age=settings.admin_session_max_age, + same_site="lax", + https_only=settings.is_prod, + ) + app.include_router(health.router) + app.include_router(auth.router) app.include_router(pages.router) app.include_router(messages.router) app.include_router(schedules.router) diff --git a/admin/middleware.py b/admin/middleware.py new file mode 100644 index 0000000..21c7a0e --- /dev/null +++ b/admin/middleware.py @@ -0,0 +1,47 @@ +"""Middleware админки: пускает дальше только вошедших администраторов.""" + +from __future__ import annotations + +import logging +import secrets +from urllib.parse import quote + +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.requests import Request +from starlette.responses import RedirectResponse, Response + +from common.repositories import admins_repo + +logger = logging.getLogger(__name__) + +#: Эндпоинты без авторизации. +PUBLIC_PATHS = frozenset({"/healthz", "/login", "/auth/telegram"}) + + +class AdminAuthMiddleware(BaseHTTPMiddleware): + """Сессия + роль из БД на каждом запросе (кроме служебных и логина).""" + + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + if request.url.path in PUBLIC_PATHS: + return await call_next(request) + + admin_id = request.session.get("admin_id") if "session" in request.scope else None + if not admin_id: + target = quote(request.url.path) + return RedirectResponse(url=f"/login?next={target}", status_code=303) + + try: + admin = await admins_repo.get_admin(int(admin_id)) + except Exception: + logger.exception("Не удалось проверить администратора") + return RedirectResponse(url="/login", status_code=303) + + if admin is None or not admin["enabled"]: + request.session.clear() + return RedirectResponse(url="/login", status_code=303) + + request.state.admin = admin + # CSRF-токен живёт в сессии; если её создали раньше — добираем. + if not request.session.get("csrf"): + request.session["csrf"] = secrets.token_urlsafe(32) + return await call_next(request) diff --git a/admin/routes/auth.py b/admin/routes/auth.py new file mode 100644 index 0000000..4200a57 --- /dev/null +++ b/admin/routes/auth.py @@ -0,0 +1,98 @@ +"""Вход в админку через Telegram Login Widget.""" + +from __future__ import annotations + +import logging +import secrets + +from fastapi import APIRouter, Request, Response +from fastapi.responses import HTMLResponse, RedirectResponse + +from admin.security import check_csrf, safe_redirect_target, verify_telegram_auth +from common import __version__ +from common.repositories import 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), + "next": target, + }, + ) + + +@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) diff --git a/admin/routes/messages.py b/admin/routes/messages.py index 2ad7550..2793cd9 100644 --- a/admin/routes/messages.py +++ b/admin/routes/messages.py @@ -8,6 +8,7 @@ from fastapi import APIRouter, HTTPException, Request, Response from fastapi.responses import HTMLResponse, RedirectResponse from admin.preview import SAMPLE_CONTEXT +from admin.security import check_csrf from common import __version__ from common.repositories import templates_repo from common.texts.render import MAX_MESSAGE_LENGTH, TAG_REGISTRY, TemplateError, render, validate @@ -56,6 +57,7 @@ async def preview_message(request: Request) -> HTMLResponse: но скрипты из введённого текста не выполняются. """ form = await request.form() + check_csrf(request, str(form.get("csrf_token", ""))) text = str(form.get("text", "")) unknown = validate(text) @@ -85,6 +87,7 @@ async def preview_message(request: Request) -> HTMLResponse: async def save_message(request: Request, key: str) -> Response: """Сохранить текст шаблона; при ошибках вернуть редактор с подсветкой.""" form = await request.form() + check_csrf(request, str(form.get("csrf_token", ""))) text = str(form.get("text", "")) template = await templates_repo.get_template(key) @@ -109,7 +112,8 @@ async def save_message(request: Request, key: str) -> Response: status_code=400, ) - await templates_repo.update_template(key, text) + editor_id = int(request.state.admin["telegram_id"]) + await templates_repo.update_template(key, text, editor_id=editor_id) return RedirectResponse(url=f"/messages/{key}?saved=1", status_code=303) diff --git a/admin/routes/schedules.py b/admin/routes/schedules.py index 008079c..4e9b2b2 100644 --- a/admin/routes/schedules.py +++ b/admin/routes/schedules.py @@ -9,12 +9,18 @@ from fastapi import APIRouter, HTTPException, Request, Response from fastapi.responses import HTMLResponse, RedirectResponse from admin.forms import format_weekdays, parse_schedule_fields +from admin.security import check_csrf from common import __version__ from common.repositories import schedules_repo, templates_repo router = APIRouter(prefix="/schedules", tags=["schedules"]) +def _editor_id(request: Request) -> int: + """Telegram-ID текущего администратора (ставит middleware).""" + return int(request.state.admin["telegram_id"]) + + def _raw_from_form(form: Any) -> dict[str, Any]: return { "template_key": str(form.get("template_key", "")), @@ -107,6 +113,7 @@ async def new_schedule(request: Request) -> HTMLResponse: async def create_schedule(request: Request) -> Response: """Создать расписание (с проверкой полей и существования шаблона).""" form = await request.form() + check_csrf(request, str(form.get("csrf_token", ""))) raw = _raw_from_form(form) data, errors = parse_schedule_fields(**raw) message_templates = await templates_repo.list_templates() @@ -123,7 +130,7 @@ async def create_schedule(request: Request) -> Response: status_code=400, ) - await schedules_repo.create_schedule(data) + await schedules_repo.create_schedule(data, editor_id=_editor_id(request)) return RedirectResponse(url="/schedules", status_code=303) @@ -149,6 +156,7 @@ async def update_schedule(request: Request, schedule_id: int) -> Response: raise HTTPException(status_code=404, detail="Расписание не найдено") form = await request.form() + check_csrf(request, str(form.get("csrf_token", ""))) raw = _raw_from_form(form) data, errors = parse_schedule_fields(**raw) message_templates = await templates_repo.list_templates() @@ -167,24 +175,30 @@ async def update_schedule(request: Request, schedule_id: int) -> Response: status_code=400, ) - await schedules_repo.update_schedule(schedule_id, data) + await schedules_repo.update_schedule(schedule_id, data, editor_id=_editor_id(request)) return RedirectResponse(url="/schedules", status_code=303) @router.post("/{schedule_id}/toggle") -async def toggle_schedule(schedule_id: int) -> Response: +async def toggle_schedule(request: Request, schedule_id: int) -> Response: """Включить или выключить расписание.""" + form = await request.form() + check_csrf(request, str(form.get("csrf_token", ""))) + schedule = await schedules_repo.get_schedule(schedule_id) if schedule is None: raise HTTPException(status_code=404, detail="Расписание не найдено") - await schedules_repo.set_enabled(schedule_id, not schedule["enabled"]) + await schedules_repo.set_enabled(schedule_id, not schedule["enabled"], editor_id=_editor_id(request)) return RedirectResponse(url="/schedules", status_code=303) @router.post("/{schedule_id}/delete") -async def delete_schedule(schedule_id: int) -> Response: +async def delete_schedule(request: Request, schedule_id: int) -> Response: """Удалить расписание (история при этом сохраняется).""" - if not await schedules_repo.delete_schedule(schedule_id): + form = await request.form() + check_csrf(request, str(form.get("csrf_token", ""))) + + if not await schedules_repo.delete_schedule(schedule_id, editor_id=_editor_id(request)): raise HTTPException(status_code=404, detail="Расписание не найдено") return RedirectResponse(url="/schedules", status_code=303) diff --git a/admin/security.py b/admin/security.py new file mode 100644 index 0000000..5787491 --- /dev/null +++ b/admin/security.py @@ -0,0 +1,69 @@ +"""Безопасность админки: проверка Telegram-логина, сессия и CSRF.""" + +from __future__ import annotations + +import hashlib +import hmac +import secrets +import time +from collections.abc import Mapping + +from fastapi import HTTPException, Request + +#: Сколько живёт подпись Telegram-логина (секунды). +TELEGRAM_AUTH_MAX_AGE = 24 * 60 * 60 + +#: Допустимое расхождение часов с Telegram (секунды). +FUTURE_SKEW = 300 + + +def telegram_check_string(data: Mapping[str, object]) -> str: + """Строка для проверки подписи: отсортированные ``key=value`` без ``hash``.""" + pairs = sorted((key, str(value)) for key, value in data.items() if key != "hash") + return "\n".join(f"{key}={value}" for key, value in pairs) + + +def verify_telegram_auth( + data: Mapping[str, object], + bot_token: str, + *, + max_age: int = TELEGRAM_AUTH_MAX_AGE, + now: float | None = None, +) -> bool: + """Проверить подпись Telegram Login Widget и свежесть ``auth_date``. + + Секрет — ``SHA256(bot_token)``, подпись — HMAC-SHA256 от check-string. + """ + received = str(data.get("hash") or "") + if not received or not bot_token: + return False + + expected = hmac.new( + hashlib.sha256(bot_token.encode()).digest(), + telegram_check_string(data).encode(), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(expected, received): + return False + + try: + auth_date = int(str(data.get("auth_date") or "")) + except (TypeError, ValueError): + return False + + moment = time.time() if now is None else now + return -FUTURE_SKEW <= moment - auth_date <= max_age + + +def check_csrf(request: Request, token: str) -> None: + """Проверить CSRF-токен формы; при несовпадении — 400.""" + expected = request.session.get("csrf") + if not expected or not token or not secrets.compare_digest(str(expected), str(token)): + raise HTTPException(status_code=400, detail="CSRF-токен неверен или устарел") + + +def safe_redirect_target(value: str | None) -> str: + """Разрешить редирект только на внутренний путь (защита от open redirect).""" + if value and value.startswith("/") and not value.startswith("//"): + return value + return "/" diff --git a/admin/templates/auth/denied.html b/admin/templates/auth/denied.html new file mode 100644 index 0000000..c4d33ca --- /dev/null +++ b/admin/templates/auth/denied.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block title %}Доступ закрыт{% endblock %} + +{% block content %} +
+

Доступ закрыт

+

{{ message }}

+

+ Вернуться к входу +

+
+{% endblock %} diff --git a/admin/templates/auth/login.html b/admin/templates/auth/login.html new file mode 100644 index 0000000..05d9457 --- /dev/null +++ b/admin/templates/auth/login.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} + +{% block title %}Вход{% endblock %} + +{% block content %} +
+

Вход в админку

+

Доступ только администраторам клана.

+ + {% if configured %} +
+ +
+ {% else %} +

+ Вход не настроен: задайте BOT_TOKEN, + BOT_USERNAME и ADMIN_PUBLIC_URL. +

+ {% endif %} +
+{% endblock %} diff --git a/admin/templates/base.html b/admin/templates/base.html index b4b2f0c..d555c63 100644 --- a/admin/templates/base.html +++ b/admin/templates/base.html @@ -15,6 +15,13 @@ Главная Сообщения Расписания + {% if request.session.get('admin_id') %} + {{ request.session.get('admin_username') or 'админ' }} +
+ + +
+ {% endif %} админка · v{{ version }} diff --git a/admin/templates/messages/edit.html b/admin/templates/messages/edit.html index 92cceb5..ad22528 100644 --- a/admin/templates/messages/edit.html +++ b/admin/templates/messages/edit.html @@ -24,6 +24,7 @@ {% endif %}
+