Files
ros_control/app/security.py
T
ayurishchevandClaude Sonnet 5 26dd1f8be4 Уникальные 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>
2026-09-19 17:01:52 +03:00

82 lines
3.0 KiB
Python

import hmac
import threading
import time
from cryptography.fernet import Fernet
from fastapi import Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.config import get_settings
_bearer = HTTPBearer(auto_error=False)
def _fernet() -> Fernet:
key = get_settings().secret_key
if not key:
raise RuntimeError("SECRET_KEY не задан (ключ Fernet)")
return Fernet(key.encode())
def encrypt(value: str) -> str:
return _fernet().encrypt(value.encode()).decode()
def decrypt(token: str) -> str:
return _fernet().decrypt(token.encode()).decode()
def check_admin(user: str, password: str) -> bool:
s = get_settings()
ok_user = hmac.compare_digest(user.encode(), s.admin_user.encode())
ok_pass = hmac.compare_digest(password.encode(), s.admin_password.encode())
return ok_user and ok_pass
# --- подтверждение действий паролем (очистка журнала) и защита от перебора ---
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")