В админку можно войти через установленный Telegram

К виджету добавился deep-link: сайт создаёт одноразовый токен на 5 минут и открывает t.me/<bot>?start=login_<token>, бот проверяет администратора и подтверждает вход, а сайт забирает токен и открывает сессию. В БД хранится только SHA-256 токена; ссылка одноразовая. Если deep-link не настроен, остаётся обычный виджет.
This commit is contained in:
MpakobecEx 2026-10-25 21:54:50 +05:00
parent 08c57f9b39
commit 84fc104717
14 changed files with 387 additions and 9 deletions

View File

@ -68,6 +68,11 @@ APP_ENV=prod python -m bot # prod
HttpOnly-cookie (`ADMIN_SESSION_SECRET`), все формы защищены CSRF. Для входа нужны HttpOnly-cookie (`ADMIN_SESSION_SECRET`), все формы защищены CSRF. Для входа нужны
`BOT_TOKEN`, `BOT_USERNAME` (без @) и HTTPS-адрес `ADMIN_PUBLIC_URL`. `BOT_TOKEN`, `BOT_USERNAME` (без @) и HTTPS-адрес `ADMIN_PUBLIC_URL`.
Дополнительно — **вход через установленный Telegram**: кнопка «Открыть Telegram»
создаёт одноразовый токен (5 минут) и открывает `t.me/<bot>?start=login_<token>`;
бот подтверждает вход, сайт забирает токен и открывает сессию. Токен в БД хранится
как SHA-256 и используется один раз.
Раздел «Расписания» — CRUD рассылок (`bot.schedules` + история): время, дни недели, Раздел «Расписания» — CRUD рассылок (`bot.schedules` + история): время, дни недели,
таймзона, шаблон и вкл/выкл. Бот раз в минуту читает активные расписания из БД; если таймзона, шаблон и вкл/выкл. Бот раз в минуту читает активные расписания из БД; если
база недоступна — использует расписание из кода (совпадает с сидом `V16`). база недоступна — использует расписание из кода (совпадает с сидом `V16`).
@ -165,7 +170,7 @@ make backup E=test # на домашнем сервере: ./scripts/clickho
## Версионирование ## Версионирование
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.5.4`. - Единый источник версии — `common/version.py` (`__version__`), текущая — `0.5.5`.
- Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`). - Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3). - Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`. - Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.

View File

@ -15,7 +15,15 @@ from common.repositories import admins_repo
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
#: Эндпоинты без авторизации. #: Эндпоинты без авторизации.
PUBLIC_PATHS = frozenset({"/healthz", "/login", "/auth/telegram"}) PUBLIC_PATHS = frozenset(
{
"/healthz",
"/login",
"/auth/telegram",
"/auth/deeplink/start",
"/auth/deeplink/status",
}
)
class AdminAuthMiddleware(BaseHTTPMiddleware): class AdminAuthMiddleware(BaseHTTPMiddleware):

View File

@ -6,11 +6,11 @@ import logging
import secrets import secrets
from fastapi import APIRouter, Request, Response from fastapi import APIRouter, Request, Response
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from admin.security import check_csrf, safe_redirect_target, verify_telegram_auth from admin.security import check_csrf, safe_redirect_target, verify_telegram_auth
from common import __version__ from common import __version__
from common.repositories import admins_repo from common.repositories import admin_login_tokens_repo, admins_repo
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -45,11 +45,81 @@ async def login_page(request: Request, next: str = "/") -> Response:
"bot_username": settings.bot_username, "bot_username": settings.bot_username,
"public_url": settings.admin_public_url.rstrip("/"), "public_url": settings.admin_public_url.rstrip("/"),
"configured": bool(settings.bot_token and settings.bot_username), "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, "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") @router.get("/auth/telegram")
async def telegram_callback(request: Request) -> Response: async def telegram_callback(request: Request) -> Response:
"""Проверить подпись Telegram, роль в БД и открыть сессию.""" """Проверить подпись Telegram, роль в БД и открыть сессию."""

View File

@ -7,6 +7,12 @@
<h1 class="text-2xl font-semibold">Вход в админку</h1> <h1 class="text-2xl font-semibold">Вход в админку</h1>
<p class="mt-2 text-sm text-slate-600">Доступ только администраторам клана.</p> <p class="mt-2 text-sm text-slate-600">Доступ только администраторам клана.</p>
{% if deeplink_error %}
<p class="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
Deep-link вход не настроен: проверьте <span class="font-mono">BOT_USERNAME</span>.
</p>
{% endif %}
{% if configured %} {% if configured %}
<div class="mt-8 flex justify-center"> <div class="mt-8 flex justify-center">
<script <script
@ -18,11 +24,51 @@
data-request-access="write" data-request-access="write"
></script> ></script>
</div> </div>
{% else %} {% endif %}
{% if deeplink %}
<div class="mt-6 border-t border-slate-100 pt-5">
<a
href="/auth/deeplink/start"
class="inline-block rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
>
Открыть Telegram
</a>
<p class="mt-2 text-xs text-slate-500">Подтвердите вход в установленном приложении Telegram.</p>
<p id="deeplink-status" class="mt-2 text-sm text-sky-700">
{% if deeplink_pending %}Ожидаем подтверждение в Telegram…{% endif %}
</p>
</div>
{% endif %}
{% if not configured and not deeplink %}
<p class="mt-6 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800"> <p class="mt-6 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
Вход не настроен: задайте <span class="font-mono">BOT_TOKEN</span>, Вход не настроен: задайте <span class="font-mono">BOT_TOKEN</span>,
<span class="font-mono">BOT_USERNAME</span> и <span class="font-mono">ADMIN_PUBLIC_URL</span>. <span class="font-mono">BOT_USERNAME</span> и <span class="font-mono">ADMIN_PUBLIC_URL</span>.
</p> </p>
{% endif %} {% endif %}
</div> </div>
{% if deeplink_pending %}
<script>
(function () {
var status = document.getElementById("deeplink-status");
var timer = setInterval(function () {
fetch("/auth/deeplink/status", { credentials: "same-origin" })
.then(function (response) { return response.json(); })
.then(function (data) {
if (data.status === "ok") {
clearInterval(timer);
window.location.href = "/";
} else if (data.status === "denied") {
clearInterval(timer);
if (status) status.textContent = "У вас нет доступа к админке.";
}
})
.catch(function () {});
}, 2000);
setTimeout(function () { clearInterval(timer); }, 300000);
})();
</script>
{% endif %}
{% endblock %} {% endblock %}

View File

@ -25,7 +25,7 @@ def create_dispatcher() -> Dispatcher:
Порядок важен: сначала события участников и команды, затем магазин, Порядок важен: сначала события участников и команды, затем магазин,
и в конце — экономика (в ней есть catch-all обработчик сообщений). и в конце — экономика (в ней есть catch-all обработчик сообщений).
""" """
from bot.handlers import commands, economy, member_events, shop from bot.handlers import commands, economy, login, member_events, shop
from bot.handlers.errors import register_errors from bot.handlers.errors import register_errors
from bot.middlewares import AnalyticsMiddleware from bot.middlewares import AnalyticsMiddleware
@ -33,6 +33,7 @@ def create_dispatcher() -> Dispatcher:
# Внешний middleware: ловит все сообщения до фильтров (события `message`/`command`). # Внешний middleware: ловит все сообщения до фильтров (события `message`/`command`).
dp.message.outer_middleware(AnalyticsMiddleware()) dp.message.outer_middleware(AnalyticsMiddleware())
dp.include_router(member_events.router) dp.include_router(member_events.router)
dp.include_router(login.router)
dp.include_router(commands.router) dp.include_router(commands.router)
dp.include_router(shop.router) dp.include_router(shop.router)
dp.include_router(economy.router) dp.include_router(economy.router)

View File

@ -15,6 +15,7 @@ logger = logging.getLogger(__name__)
#: Ожидаемый набор команд в меню (порядок = порядок в Telegram). #: Ожидаемый набор команд в меню (порядок = порядок в Telegram).
BOT_COMMANDS: tuple[BotCommand, ...] = ( BOT_COMMANDS: tuple[BotCommand, ...] = (
BotCommand(command="start", description="Начать"),
BotCommand(command="help", description="Помощь"), BotCommand(command="help", description="Помощь"),
BotCommand(command="info", description="Инфо"), BotCommand(command="info", description="Инфо"),
BotCommand(command="nick", description="Сменить ник"), BotCommand(command="nick", description="Сменить ник"),

50
bot/handlers/login.py Normal file
View File

@ -0,0 +1,50 @@
"""Вход в админку через deep-link из установленного Telegram."""
from __future__ import annotations
from aiogram import Router
from aiogram.filters import Command
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message
from common.config import get_settings
from common.repositories import admin_login_tokens_repo, admins_repo
from common.texts import system
router = Router(name="login")
@router.message(Command("start"))
async def start_handler(message: Message) -> None:
"""Обработать /start; payload ``login_<token>`` подтверждает вход в админку."""
if message.chat.type != "private" or message.from_user is None:
return
parts = (message.text or "").split(maxsplit=1)
payload = parts[1].strip() if len(parts) > 1 else ""
if not payload.startswith("login_"):
await message.answer(system.MSG_START)
return
admin = await admins_repo.get_admin(message.from_user.id)
if admin is None or not admin["enabled"]:
await message.answer(system.MSG_LOGIN_DENIED)
return
if not await admin_login_tokens_repo.confirm_token(payload[6:], message.from_user.id):
await message.answer(system.MSG_LOGIN_INVALID)
return
settings = get_settings()
markup = None
if settings.admin_public_url.startswith(("http://", "https://")):
markup = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="Вернуться в админку",
url=settings.admin_public_url.rstrip("/") + "/login",
)
]
]
)
await message.answer(system.MSG_LOGIN_CONFIRMED, reply_markup=markup)

View File

@ -57,6 +57,9 @@ class Settings(BaseSettings):
#: Общий каталог картинок (Docker volume), который видят бот и админка. #: Общий каталог картинок (Docker volume), который видят бот и админка.
media_dir: str = "/app/media" media_dir: str = "/app/media"
#: Публичный адрес админки (deep-link вход, ссылки из бота).
admin_public_url: str = "http://localhost:8000"
#: ClickHouse (аналитика событий). CLICKHOUSE_ENABLED=false — полностью выключить. #: ClickHouse (аналитика событий). CLICKHOUSE_ENABLED=false — полностью выключить.
clickhouse_enabled: bool = True clickhouse_enabled: bool = True
clickhouse_host: str = "localhost" clickhouse_host: str = "localhost"
@ -144,7 +147,7 @@ class AdminSettings(Settings):
admin_port: int = 8000 admin_port: int = 8000
#: Публичный адрес админки (для Telegram Login Widget; нужен HTTPS). #: Публичный адрес админки (для Telegram Login Widget; нужен HTTPS).
admin_public_url: str = "http://localhost:8000" #: Наследуется из базовых настроек: ADMIN_PUBLIC_URL.
#: Username бота без @ — из него виджет рисует кнопку входа. #: Username бота без @ — из него виджет рисует кнопку входа.
bot_username: str = "" bot_username: str = ""
#: Секрет подписи cookie-сессии. В проде обязателен, иначе сессии слетают #: Секрет подписи cookie-сессии. В проде обязателен, иначе сессии слетают

View File

@ -0,0 +1,73 @@
"""Одноразовые токены входа в админку через Telegram deep-link."""
from __future__ import annotations
import hashlib
import secrets
from common.db.pool import get_pool
#: Время жизни токена.
TTL_MINUTES = 5
def _hash(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
async def create_token() -> str:
"""Создать токен, вернуть его в открытом виде (в БД — только хеш)."""
token = secrets.token_urlsafe(32)
async with get_pool().connection() as conn:
await conn.execute(
"""
INSERT INTO bot.admin_login_tokens (token_hash, expires_at)
VALUES (%s, now() + make_interval(mins => %s))
""",
(_hash(token), TTL_MINUTES),
)
await conn.execute("DELETE FROM bot.admin_login_tokens WHERE expires_at < now() - interval '1 day'")
return token
async def confirm_token(token: str, telegram_id: int) -> bool:
"""Отметить токен подтверждённым ботом. False — если истёк/уже подтверждён."""
async with get_pool().connection() as conn:
cur = await conn.execute(
"""
UPDATE bot.admin_login_tokens
SET confirmed_at = now(), telegram_id = %s
WHERE token_hash = %s
AND confirmed_at IS NULL
AND used_at IS NULL
AND expires_at > now()
""",
(telegram_id, _hash(token)),
)
return bool(cur.rowcount)
async def consume_token(token: str) -> int | None:
"""Забрать подтверждённый токен один раз. Возвращает Telegram-ID или None."""
async with get_pool().connection() as conn:
cur = await conn.execute(
"""
SELECT telegram_id
FROM bot.admin_login_tokens
WHERE token_hash = %s
AND confirmed_at IS NOT NULL
AND used_at IS NULL
AND expires_at > now()
FOR UPDATE
""",
(_hash(token),),
)
row = await cur.fetchone()
if row is None or row["telegram_id"] is None:
return None
await conn.execute(
"UPDATE bot.admin_login_tokens SET used_at = now() WHERE token_hash = %s",
(_hash(token),),
)
return int(row["telegram_id"])

View File

@ -27,6 +27,11 @@ MSG_DONAT_PROMO = "Назови этот код админам!\n`{promo}`"
MSG_EGGS_TOO_EARLY = "Слишком рано..." MSG_EGGS_TOO_EARLY = "Слишком рано..."
MSG_EGGS_EVENT_OVER = "Событие с яичками уже завершилось — увидимся на следующую Пасху." MSG_EGGS_EVENT_OVER = "Событие с яичками уже завершилось — увидимся на следующую Пасху."
MSG_START = "Привет! Это бот Dispel Evil."
MSG_LOGIN_CONFIRMED = "Вход в админку подтверждён. Вернитесь на сайт."
MSG_LOGIN_DENIED = "У вас нет доступа к админке."
MSG_LOGIN_INVALID = "Ссылка для входа устарела. Начните вход заново на сайте."
MSG_BOT_ADDED = "Я пришёл! Требую админские права!" MSG_BOT_ADDED = "Я пришёл! Требую админские права!"
MSG_BOT_FOREIGN_CHAT = "Создатель запретил ходить в другие чатики :с" MSG_BOT_FOREIGN_CHAT = "Создатель запретил ходить в другие чатики :с"

View File

@ -7,4 +7,4 @@
from __future__ import annotations from __future__ import annotations
__version__ = "0.5.4" __version__ = "0.5.5"

View File

@ -0,0 +1,14 @@
-- Одноразовые токены для входа в админку через установленный Telegram (deep-link).
CREATE TABLE IF NOT EXISTS bot.admin_login_tokens (
token_hash text PRIMARY KEY,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
confirmed_at timestamptz,
used_at timestamptz,
telegram_id bigint
);
CREATE INDEX IF NOT EXISTS idx_admin_login_tokens_expires
ON bot.admin_login_tokens (expires_at);
COMMENT ON TABLE bot.admin_login_tokens IS 'Одноразовые токены deep-link входа в админку (хранится SHA-256)';

View File

@ -0,0 +1,102 @@
"""Тесты deep-link входа в админку через Telegram."""
from __future__ import annotations
from typing import Any
import pytest
from fastapi.testclient import TestClient
from common.repositories import admin_login_tokens_repo
@pytest.fixture
def fake_tokens(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
state: dict[str, Any] = {"confirmed": None}
async def create_token() -> str:
return "test-token"
async def consume_token(token: str) -> int | None:
return state["confirmed"]
monkeypatch.setattr(admin_login_tokens_repo, "create_token", create_token)
monkeypatch.setattr(admin_login_tokens_repo, "consume_token", consume_token)
return state
def _client(client_factory, settings_factory) -> TestClient:
return client_factory(
authenticated=False,
follow_redirects=False,
settings=settings_factory(
bot_username="debot_test_bot",
admin_public_url="https://debot-test.mpakosrv.ru",
),
)
def test_login_page_has_deeplink(fake_tokens: dict[str, Any], client_factory, settings_factory) -> None:
response = _client(client_factory, settings_factory).get("/login")
assert response.status_code == 200
assert "Открыть Telegram" in response.text
assert "/auth/deeplink/start" in response.text
def test_deeplink_start_redirects_to_telegram(
fake_tokens: dict[str, Any],
client_factory,
settings_factory,
) -> None:
client = _client(client_factory, settings_factory)
response = client.get("/auth/deeplink/start")
assert response.status_code == 303
assert response.headers["location"] == "https://t.me/debot_test_bot?start=login_test-token"
assert "debot_login_token" in response.headers["set-cookie"]
def test_deeplink_status_without_cookie(fake_tokens: dict[str, Any], client_factory, settings_factory) -> None:
response = _client(client_factory, settings_factory).get("/auth/deeplink/status")
assert response.json() == {"status": "none"}
def test_deeplink_status_pending(fake_tokens: dict[str, Any], client_factory, settings_factory) -> None:
client = _client(client_factory, settings_factory)
client.get("/auth/deeplink/start")
response = client.get("/auth/deeplink/status")
assert response.json() == {"status": "pending"}
def test_deeplink_status_confirms_session(
fake_tokens: dict[str, Any],
client_factory,
settings_factory,
) -> None:
fake_tokens["confirmed"] = 1
client = _client(client_factory, settings_factory)
client.get("/auth/deeplink/start")
response = client.get("/auth/deeplink/status")
assert response.json() == {"status": "ok"}
assert client.get("/").status_code == 200
def test_deeplink_status_denied_for_unknown_admin(
fake_tokens: dict[str, Any],
client_factory,
settings_factory,
) -> None:
fake_tokens["confirmed"] = 2
client = _client(client_factory, settings_factory)
client.get("/auth/deeplink/start")
response = client.get("/auth/deeplink/status")
assert response.json() == {"status": "denied"}

View File

@ -7,4 +7,4 @@ from common import __version__
def test_version_is_set(): def test_version_is_set():
assert __version__ assert __version__
assert __version__ == "0.5.4" assert __version__ == "0.5.5"