- не больше 10 неудачных входов с одного адреса за 5 минут, дальше 429 (панель доступна из интернета, простой перебор закрываем); успешный вход сбрасывает счётчик; - .env.example: SETTINGS_FILE пустой — локально настройки живут в памяти, в docker compose подставляет /data/settings.json по умолчанию.
208 lines
7.3 KiB
Python
208 lines
7.3 KiB
Python
"""Админка расписания.
|
||
|
||
Отдельного входа на сайте нет: панель открывается по секретному пути из
|
||
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
|