Files
schedule/src/backend/admin.py
Vladislav Kopnin e007a85f56 Админка: защита от перебора пароля и правка дефолта SETTINGS_FILE
- не больше 10 неудачных входов с одного адреса за 5 минут, дальше 429
  (панель доступна из интернета, простой перебор закрываем);
  успешный вход сбрасывает счётчик;
- .env.example: SETTINGS_FILE пустой — локально настройки живут в памяти,
  в docker compose подставляет /data/settings.json по умолчанию.
2026-09-28 12:07:45 +03:00

208 lines
7.3 KiB
Python
Raw 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.

"""Админка расписания.
Отдельного входа на сайте нет: панель открывается по секретному пути из
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