Уникальные ID сущностей, журнал событий и его UI с ротацией и очисткой
Глобально уникальные ID (docs/changes/016): - у устройств, групп, задач, резервных копий и записей журнала ID вида <префикс>_<uuid7> (dev_, grp_, job_, bkp_, evt_): типы не пересекаются, внутри типа ID не повторяются и сортируются по времени; - миграция БД v1 заменяет числовые ID с пересчётом ссылок (копия файла БД перед миграцией, сверка числа строк, одна транзакция); - резервная копия = пара файлов с одним bkp_ ID, файлы в S3 получают метаданные backup-id/device-id; синхронизация метаданных с бакетом; - журнал событий в БД (events): создание/изменение/удаление, задачи, бэкапы, смена online/offline, вход в UI; API чтения GET /api/v1/events. Журнал в UI, ротация и очистка (docs/changes/017): - страница «Журнал»: фильтры, подгрузка «Показать ещё», окно записи; - настройки ротации (срок и максимум записей) хранятся в БД (схема v2), ротация при старте, раз в час и после сохранения настроек; - очистка журнала только через окно с паролем пользователя, блокировка после 5 неверных попыток, остаётся запись о факте очистки; через API очистки нет. Тесты: 20 из 20. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1e5fb17755
commit
26dd1f8be4
43 files changed
+1677
-157
No files matched your search
+45
-1
@@ -1,4 +1,6 @@
|
||||
import hmac
|
||||
import threading
|
||||
import time
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from fastapi import Depends, HTTPException
|
||||
@@ -31,7 +33,49 @@ def check_admin(user: str, password: str) -> bool:
|
||||
return ok_user and ok_pass
|
||||
|
||||
|
||||
def require_api_token(cred: HTTPAuthorizationCredentials | None = Depends(_bearer)) -> None:
|
||||
# --- подтверждение действий паролем (очистка журнала) и защита от перебора ---
|
||||
FAIL_LIMIT, WINDOW_S, LOCK_S = 5, 600, 600 # 5 неверных за 10 минут → блокировка на 10 минут
|
||||
_guard = threading.Lock()
|
||||
_fails: dict[str, list[float]] = {}
|
||||
_locked_until: dict[str, float] = {}
|
||||
|
||||
|
||||
def verify_password(user: str, password: str) -> bool:
|
||||
"""Пароль пользователя сессии (единственный пользователь — ADMIN_USER); сравнение за постоянное время."""
|
||||
s = get_settings()
|
||||
return (hmac.compare_digest(user.encode(), s.admin_user.encode())
|
||||
and hmac.compare_digest(password.encode(), s.admin_password.encode()))
|
||||
|
||||
|
||||
def lockout_remaining(user: str) -> int:
|
||||
"""Сколько секунд осталось до конца блокировки (0 — не заблокирован)."""
|
||||
with _guard:
|
||||
return max(0, int(_locked_until.get(user, 0) - time.monotonic() + 0.999))
|
||||
|
||||
|
||||
def register_failure(user: str) -> int:
|
||||
"""Учитывает неверный пароль; возвращает число оставшихся попыток (0 — пользователь заблокирован)."""
|
||||
now = time.monotonic()
|
||||
with _guard:
|
||||
recent = [t for t in _fails.get(user, []) if now - t < WINDOW_S] + [now]
|
||||
_fails[user] = recent
|
||||
if len(recent) >= FAIL_LIMIT:
|
||||
_locked_until[user] = now + LOCK_S
|
||||
_fails[user] = []
|
||||
return 0
|
||||
return FAIL_LIMIT - len(recent)
|
||||
|
||||
|
||||
def reset_failures(user: str) -> None:
|
||||
with _guard:
|
||||
_fails.pop(user, None)
|
||||
_locked_until.pop(user, None)
|
||||
|
||||
|
||||
async def require_api_token(cred: HTTPAuthorizationCredentials | None = Depends(_bearer)) -> None:
|
||||
expected = get_settings().api_token
|
||||
if cred is None or not hmac.compare_digest(cred.credentials.encode(), expected.encode()):
|
||||
raise HTTPException(status_code=401, detail="Invalid or missing API token")
|
||||
from app.services import events # локальный импорт: security загружается раньше сервисов
|
||||
|
||||
events.set_actor("api")
|
||||
Reference in new issue
Block a user