2026-09-19 13:13:29 +03:00
|
|
|
import hmac
|
2026-09-19 17:01:52 +03:00
|
|
|
import threading
|
|
|
|
|
import time
|
2026-09-19 13:13:29 +03:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-09-28 12:41:58 +03:00
|
|
|
# --- защита от перебора: вход в UI (по IP) и подтверждение действий паролем (очистка журнала, по пользователю) ---
|
2026-09-19 17:01:52 +03:00
|
|
|
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()))
|
|
|
|
|
|
|
|
|
|
|
2026-09-28 12:41:58 +03:00
|
|
|
def lockout_remaining(key: str) -> int:
|
|
|
|
|
"""Сколько секунд осталось до конца блокировки по ключу (0 — не заблокирован).
|
|
|
|
|
|
|
|
|
|
Ключ — своё пространство на каждый вид перебора (например, `f"login:{ip}"` для входа в UI
|
|
|
|
|
и имя пользователя для очистки журнала), чтобы счётчики не пересекались."""
|
2026-09-19 17:01:52 +03:00
|
|
|
with _guard:
|
2026-09-28 12:41:58 +03:00
|
|
|
return max(0, int(_locked_until.get(key, 0) - time.monotonic() + 0.999))
|
2026-09-19 17:01:52 +03:00
|
|
|
|
|
|
|
|
|
2026-09-28 12:41:58 +03:00
|
|
|
def register_failure(key: str) -> int:
|
|
|
|
|
"""Учитывает неверную попытку по ключу; возвращает число оставшихся попыток (0 — ключ заблокирован)."""
|
2026-09-19 17:01:52 +03:00
|
|
|
now = time.monotonic()
|
|
|
|
|
with _guard:
|
2026-09-28 12:41:58 +03:00
|
|
|
recent = [t for t in _fails.get(key, []) if now - t < WINDOW_S] + [now]
|
|
|
|
|
_fails[key] = recent
|
2026-09-19 17:01:52 +03:00
|
|
|
if len(recent) >= FAIL_LIMIT:
|
2026-09-28 12:41:58 +03:00
|
|
|
_locked_until[key] = now + LOCK_S
|
|
|
|
|
_fails[key] = []
|
2026-09-19 17:01:52 +03:00
|
|
|
return 0
|
|
|
|
|
return FAIL_LIMIT - len(recent)
|
|
|
|
|
|
|
|
|
|
|
2026-09-28 12:41:58 +03:00
|
|
|
def reset_failures(key: str) -> None:
|
2026-09-19 17:01:52 +03:00
|
|
|
with _guard:
|
2026-09-28 12:41:58 +03:00
|
|
|
_fails.pop(key, None)
|
|
|
|
|
_locked_until.pop(key, None)
|
2026-09-19 17:01:52 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
async def require_api_token(cred: HTTPAuthorizationCredentials | None = Depends(_bearer)) -> None:
|
2026-09-19 13:13:29 +03:00
|
|
|
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")
|
2026-09-19 17:01:52 +03:00
|
|
|
from app.services import events # локальный импорт: security загружается раньше сервисов
|
|
|
|
|
|
|
|
|
|
events.set_actor("api")
|