Админка нужна на «голом» сервере, поэтому возвращаю её в основную ветку. Она выключена, пока не заданы ADMIN_LOGIN/ADMIN_PASSWORD: маршруты /api/admin/* не регистрируются, страницы нет — обычная установка ничего не замечает. Панель (открывается по секретному пути ADMIN_PATH, кнопки входа на сайте нет, вход по логину/паролю): - состояние: группы, когда обновлялось, дата файла на Диске, изменился ли файл при последнем чтении (sha256), ошибки, путь файла настроек; - новая ссылка на таблицу: проверяется скачиванием, при ошибке откат; - интервал обновления (TTL) в минутах; - «Обновить сейчас»: принудительно перечитывает таблицу (качает файл всегда, интервал — только если на Диске изменилась дата); - история входов: последняя попытка (откуда, когда, успешна ли) и последний успешный вход — и в панели, и в логе службы. Защита входа: ADMIN_MAX_ATTEMPTS неудач (по умолчанию 3), затем блокировка на ADMIN_LOCKOUT_MINUTES минут (по умолчанию 15) — всё из .env. Реализация: - backend/admin.py: cookie-сессия на HMAC (ключ — пароль, 12 ч, HttpOnly, SameSite, Secure за https), сравнение за постоянное время, реальный IP из X-Forwarded-For, логирование каждой попытки; - schedule/settings.py: ссылка, интервал и история входов в JSON; путь по умолчанию data/settings.json (в docker — том schedule_data), при недоступности файла настройки живут в памяти и панель предупреждает; - cache.py: apply_link (с проверкой и откатом), force_reload, state(); - frontend: страница админки по ADMIN_PATH (вне static, noindex) и прокси /api/admin/* с телом и Set-Cookie; - install.sh пишет переменные админки в .env, Dockerfile копирует admin.py. Тесты: 156. Проверено локально (страница, 3 попытки -> блокировка, история) и в собранном docker-образе (том настроек, запись истории).
279 lines
11 KiB
Python
279 lines
11 KiB
Python
"""Админка расписания.
|
|
|
|
Отдельного входа на сайте нет: панель открывается по секретному пути из
|
|
ADMIN_PATH, логин и пароль задаются в ADMIN_LOGIN/ADMIN_PASSWORD. Если они
|
|
пусты, админка выключена и маршруты /api/admin/* не регистрируются.
|
|
|
|
После нескольких неудачных попыток вход блокируется на время из
|
|
ADMIN_LOCKOUT_MINUTES (по умолчанию: 3 попытки и 15 минут). Каждая попытка
|
|
запоминается (откуда, когда, успешна ли) — это видно в панели и пишется в лог.
|
|
|
|
Что умеет администратор:
|
|
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 datetime import datetime
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
|
from pydantic import BaseModel
|
|
|
|
from schedule import ScheduleCache, ScheduleError
|
|
from schedule.settings import SettingsStore
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
COOKIE_NAME = "schedule_admin"
|
|
SESSION_TTL_SECONDS = 12 * 3600
|
|
|
|
DEFAULT_MAX_ATTEMPTS = 3
|
|
DEFAULT_LOCKOUT_MINUTES = 15
|
|
|
|
|
|
@dataclass
|
|
class AdminConfig:
|
|
"""Настройки админки: секретный путь, учётные данные и защита от перебора."""
|
|
|
|
path: str = ""
|
|
login: str = ""
|
|
password: str = ""
|
|
max_attempts: int = DEFAULT_MAX_ATTEMPTS
|
|
lockout_minutes: int = DEFAULT_LOCKOUT_MINUTES
|
|
|
|
@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 LoginThrottle:
|
|
"""Счётчик неудачных попыток: N промахов — пауза на lockout_minutes."""
|
|
|
|
def __init__(self, max_attempts: int = DEFAULT_MAX_ATTEMPTS, lockout_minutes: int = DEFAULT_LOCKOUT_MINUTES) -> None:
|
|
self.max_attempts = max(1, int(max_attempts))
|
|
self.lockout_seconds = max(1, int(lockout_minutes)) * 60
|
|
self._lock = threading.Lock()
|
|
self._failures: dict[str, int] = {}
|
|
self._blocked_until: dict[str, float] = {}
|
|
|
|
def locked_until(self, key: str) -> float | None:
|
|
with self._lock:
|
|
until = self._blocked_until.get(key)
|
|
if until is None:
|
|
return None
|
|
if until <= time.time():
|
|
self._blocked_until.pop(key, None)
|
|
self._failures.pop(key, None)
|
|
return None
|
|
return until
|
|
|
|
def attempts_left(self, key: str) -> int:
|
|
with self._lock:
|
|
return max(0, self.max_attempts - self._failures.get(key, 0))
|
|
|
|
def register_failure(self, key: str) -> None:
|
|
with self._lock:
|
|
failures = self._failures.get(key, 0) + 1
|
|
if failures >= self.max_attempts:
|
|
self._blocked_until[key] = time.time() + self.lockout_seconds
|
|
self._failures[key] = 0
|
|
else:
|
|
self._failures[key] = failures
|
|
|
|
def reset(self, key: str) -> None:
|
|
with self._lock:
|
|
self._failures.pop(key, None)
|
|
self._blocked_until.pop(key, None)
|
|
|
|
|
|
class LoginBody(BaseModel):
|
|
login: str
|
|
password: str
|
|
|
|
|
|
class LinkBody(BaseModel):
|
|
link: str
|
|
|
|
|
|
class IntervalBody(BaseModel):
|
|
interval_minutes: int
|
|
|
|
|
|
def _client_ip(request: Request) -> str:
|
|
"""Реальный адрес клиента: за nginx он приходит в X-Forwarded-For."""
|
|
forwarded = request.headers.get("x-forwarded-for", "")
|
|
if forwarded:
|
|
return forwarded.split(",")[0].strip() or "неизвестно"
|
|
return request.client.host if request.client else "неизвестно"
|
|
|
|
|
|
def _human_dt(iso: str | None) -> str:
|
|
if not iso:
|
|
return "—"
|
|
try:
|
|
return datetime.fromisoformat(iso).strftime("%d.%m.%Y %H:%M:%S")
|
|
except ValueError:
|
|
return iso
|
|
|
|
|
|
def create_admin_router(
|
|
cache: ScheduleCache,
|
|
settings: SettingsStore | None,
|
|
config: AdminConfig,
|
|
) -> APIRouter:
|
|
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
|
throttle = LoginThrottle(config.max_attempts, config.lockout_minutes)
|
|
|
|
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 _remember(ip: str, ok: bool) -> None:
|
|
if settings is not None:
|
|
settings.record_login(ip, ok)
|
|
|
|
@router.post("/login")
|
|
def login(body: LoginBody, request: Request, response: Response) -> dict:
|
|
ip = _client_ip(request)
|
|
until = throttle.locked_until(ip)
|
|
if until is not None:
|
|
minutes = max(1, round((until - time.time()) / 60))
|
|
log.warning("Вход в админку заблокирован (%s), осталось ~%d мин", ip, minutes)
|
|
raise HTTPException(
|
|
status_code=429,
|
|
detail=f"Слишком много неудачных попыток. Вход заблокирован ещё на ~{minutes} мин.",
|
|
)
|
|
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(ip)
|
|
_remember(ip, False)
|
|
left = throttle.attempts_left(ip)
|
|
if throttle.locked_until(ip) is not None:
|
|
log.warning(
|
|
"Неудачная попытка входа (%s): исчерпаны попытки, пауза %d мин",
|
|
ip,
|
|
config.lockout_minutes,
|
|
)
|
|
raise HTTPException(
|
|
status_code=429,
|
|
detail=f"Неверный логин или пароль. Попытки исчерпаны — вход заблокирован на {config.lockout_minutes} мин.",
|
|
)
|
|
log.warning("Неудачная попытка входа (%s), осталось попыток: %d", ip, left)
|
|
raise HTTPException(
|
|
status_code=401,
|
|
detail=f"Неверный логин или пароль. Осталось попыток: {left}.",
|
|
)
|
|
throttle.reset(ip)
|
|
previous = settings.last_attempt if settings is not None else None
|
|
_remember(ip, True)
|
|
if previous:
|
|
log.info(
|
|
"Успешный вход в админку (%s). Предыдущая попытка: %s, %s, %s",
|
|
ip,
|
|
_human_dt(previous.get("at")),
|
|
previous.get("ip"),
|
|
"успешная" if previous.get("ok") else "неудачная",
|
|
)
|
|
else:
|
|
log.info("Успешный вход в админку (%s)", ip)
|
|
response.set_cookie(
|
|
COOKIE_NAME,
|
|
make_token(config.login, config.password),
|
|
max_age=SESSION_TTL_SECONDS,
|
|
httponly=True,
|
|
samesite="lax",
|
|
secure=_secure(request),
|
|
path="/",
|
|
)
|
|
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(request: Request) -> dict:
|
|
ip = _client_ip(request)
|
|
until = throttle.locked_until(ip)
|
|
payload = cache.state()
|
|
payload["throttle"] = {
|
|
"max_attempts": config.max_attempts,
|
|
"lockout_minutes": config.lockout_minutes,
|
|
"attempts_left": throttle.attempts_left(ip),
|
|
"locked_until": datetime.fromtimestamp(until).astimezone().isoformat() if until else None,
|
|
}
|
|
return payload
|
|
|
|
@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
|