"""Админка расписания. Отдельного входа на сайте нет: панель открывается по секретному пути из ADMIN_PATH, логин и пароль задаются в ADMIN_LOGIN/ADMIN_PASSWORD. После входа ставим подписанную HMAC-cookie (ключ — пароль), поэтому состояние между запросами не хранится. Что умеет администратор: GET /api/admin/state — текущая ссылка, интервал, состояние кэша; POST /api/admin/link — проверить и поставить новую ссылку; POST /api/admin/interval — интервал обновления в минутах; POST /api/admin/refresh — принудительно обновить расписание сейчас. """ from __future__ import annotations import base64 import hashlib import hmac import json import logging import threading import time from dataclasses import dataclass from fastapi import APIRouter, Depends, HTTPException, Request, Response from pydantic import BaseModel from schedule import ScheduleCache, ScheduleError log = logging.getLogger(__name__) COOKIE_NAME = "schedule_admin" SESSION_TTL_SECONDS = 12 * 3600 # защита от перебора пароля: не больше N неудач с одного адреса за окно LOGIN_MAX_FAILURES = 10 LOGIN_WINDOW_SECONDS = 300.0 class LoginThrottle: """Простейший счётчик неудачных попыток входа по IP.""" def __init__( self, max_failures: int = LOGIN_MAX_FAILURES, window: float = LOGIN_WINDOW_SECONDS, ) -> None: self._max = max_failures self._window = window self._lock = threading.Lock() self._failures: dict[str, list[float]] = {} def _recent(self, key: str, now: float) -> list[float]: return [ts for ts in self._failures.get(key, []) if now - ts < self._window] def blocked(self, key: str) -> bool: now = time.time() with self._lock: recent = self._recent(key, now) self._failures[key] = recent return len(recent) >= self._max def register_failure(self, key: str) -> None: now = time.time() with self._lock: self._failures[key] = [*self._recent(key, now), now] def reset(self, key: str) -> None: with self._lock: self._failures.pop(key, None) @dataclass class AdminConfig: """Настройки админки: секретный путь и учётные данные из .env.""" path: str = "" login: str = "" password: str = "" @property def enabled(self) -> bool: return bool(self.login and self.password) def _b64e(raw: bytes) -> str: return base64.urlsafe_b64encode(raw).decode().rstrip("=") def _b64d(text: str) -> bytes: return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) def _same(left: str, right: str) -> bool: """Сравнение строк за постоянное время (compare_digest не умеет не-ASCII).""" return hmac.compare_digest(left.encode("utf-8"), right.encode("utf-8")) def make_token(login: str, password: str, ttl: int = SESSION_TTL_SECONDS) -> str: payload = json.dumps( {"sub": login, "exp": int(time.time()) + ttl}, separators=(",", ":") ).encode() sig = hmac.new(password.encode(), payload, hashlib.sha256).digest() return f"{_b64e(payload)}.{_b64e(sig)}" def verify_token(token: str, password: str) -> bool: try: payload_b64, sig_b64 = token.split(".", 1) payload = _b64d(payload_b64) expected = hmac.new(password.encode(), payload, hashlib.sha256).digest() if not hmac.compare_digest(expected, _b64d(sig_b64)): return False return int(json.loads(payload).get("exp", 0)) > time.time() except (ValueError, TypeError): return False class LoginBody(BaseModel): login: str password: str class LinkBody(BaseModel): link: str class IntervalBody(BaseModel): interval_minutes: int def create_admin_router(cache: ScheduleCache, config: AdminConfig) -> APIRouter: router = APIRouter(prefix="/api/admin", tags=["admin"]) throttle = LoginThrottle() def require_auth(request: Request) -> None: token = request.cookies.get(COOKIE_NAME, "") if not token or not verify_token(token, config.password): raise HTTPException(status_code=401, detail="Требуется вход в админку") def _secure(request: Request) -> bool: proto = request.headers.get("x-forwarded-proto", "") or request.url.scheme return proto.split(",")[0].strip() == "https" def _client_key(request: Request) -> str: return request.client.host if request.client else "unknown" @router.post("/login") def login(body: LoginBody, request: Request, response: Response) -> dict: key = _client_key(request) if throttle.blocked(key): log.warning("Вход в админку: слишком много неудачных попыток с %s", key) raise HTTPException( status_code=429, detail="Слишком много попыток входа, попробуйте позже" ) ok_login = _same(body.login.strip(), config.login) ok_password = _same(body.password, config.password) if not (ok_login and ok_password): throttle.register_failure(key) log.warning("Неудачная попытка входа в админку (логин %r)", body.login[:40]) raise HTTPException(status_code=401, detail="Неверный логин или пароль") throttle.reset(key) response.set_cookie( COOKIE_NAME, make_token(config.login, config.password), max_age=SESSION_TTL_SECONDS, httponly=True, samesite="lax", secure=_secure(request), path="/", ) log.info("Вход в админку выполнен") return {"ok": True} @router.post("/logout") def logout(response: Response) -> dict: response.delete_cookie(COOKIE_NAME, path="/") return {"ok": True} @router.get("/state", dependencies=[Depends(require_auth)]) def state() -> dict: return cache.state() @router.post("/refresh", dependencies=[Depends(require_auth)]) def refresh() -> dict: try: return cache.force_reload() except ScheduleError as exc: raise HTTPException(status_code=502, detail=str(exc)) @router.post("/link", dependencies=[Depends(require_auth)]) def set_link(body: LinkBody) -> dict: try: return cache.apply_link(body.link) except ScheduleError as exc: raise HTTPException(status_code=400, detail=str(exc)) @router.post("/interval", dependencies=[Depends(require_auth)]) def set_interval(body: IntervalBody) -> dict: try: minutes = cache.set_interval_minutes(body.interval_minutes) except ScheduleError as exc: raise HTTPException(status_code=400, detail=str(exc)) return {"interval_minutes": minutes} return router