from __future__ import annotations import hmac import json import os import secrets import time from dataclasses import dataclass from datetime import datetime from pathlib import Path from threading import RLock from typing import Any from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from fastapi import APIRouter, HTTPException, Request from fastapi.responses import JSONResponse from pydantic import BaseModel, Field DEFAULT_SECURITY: dict[str, Any] = { "daily_mask": "4827", "master_pin": "638194", "timezone": "Europe/Moscow", "session_minutes": 30, "max_attempts": 5, "lock_seconds": 30, "cookie_secure": False, } COOKIE_NAME = "ui_builder_editor_session" class PinPayload(BaseModel): pin: str = Field(min_length=4, max_length=12) @dataclass class FailureState: attempts: int = 0 locked_until: float = 0.0 class EditorAuthManager: """Local PIN guard for the visual editor. Daily PIN: digits(DDMM) + digits(mask), each result modulo 10. Example: 1607 + 4827 = 5424 """ def __init__(self, settings_dir: Path) -> None: self.settings_dir = Path(settings_dir) self.security_file = self.settings_dir / "editor_security.json" self.settings_dir.mkdir(parents=True, exist_ok=True) self._lock = RLock() self._sessions: dict[str, float] = {} self._failures: dict[str, FailureState] = {} self.settings = self._load_settings() def _load_settings(self) -> dict[str, Any]: if not self.security_file.exists(): self.security_file.write_text( json.dumps(DEFAULT_SECURITY, ensure_ascii=False, indent=2), encoding="utf-8", ) try: raw = json.loads(self.security_file.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError, TypeError): raw = {} result = dict(DEFAULT_SECURITY) if isinstance(raw, dict): result.update(raw) overrides = { "daily_mask": os.getenv("EDITOR_PIN_MASK"), "master_pin": os.getenv("EDITOR_MASTER_PIN"), "timezone": os.getenv("EDITOR_PIN_TIMEZONE"), "session_minutes": os.getenv("EDITOR_SESSION_MINUTES"), "max_attempts": os.getenv("EDITOR_MAX_ATTEMPTS"), "lock_seconds": os.getenv("EDITOR_LOCK_SECONDS"), } for key, value in overrides.items(): if value not in (None, ""): result[key] = value cookie_override = os.getenv("EDITOR_COOKIE_SECURE") if cookie_override not in (None, ""): result["cookie_secure"] = cookie_override.strip().lower() in { "1", "true", "yes", "on" } mask = "".join(character for character in str(result["daily_mask"]) if character.isdigit()) result["daily_mask"] = mask[:4].ljust(4, "0") if mask else "4827" master = "".join(character for character in str(result["master_pin"]) if character.isdigit()) result["master_pin"] = master if 4 <= len(master) <= 12 else "638194" try: ZoneInfo(str(result["timezone"])) except ZoneInfoNotFoundError: result["timezone"] = "UTC" for key, low, high, fallback in ( ("session_minutes", 1, 1440, 30), ("max_attempts", 1, 20, 5), ("lock_seconds", 5, 3600, 30), ): try: value = int(result[key]) except (TypeError, ValueError): value = fallback result[key] = max(low, min(high, value)) return result @property def timezone(self) -> ZoneInfo: return ZoneInfo(str(self.settings["timezone"])) @property def session_seconds(self) -> int: return int(self.settings["session_minutes"]) * 60 def now(self) -> datetime: return datetime.now(self.timezone) def daily_pin(self, moment: datetime | None = None) -> str: current = moment.astimezone(self.timezone) if moment else self.now() date_digits = current.strftime("%d%m") mask = str(self.settings["daily_mask"]) return "".join( str((int(date_digit) + int(mask_digit)) % 10) for date_digit, mask_digit in zip(date_digits, mask, strict=True) ) def explain_daily_pin(self, moment: datetime | None = None) -> dict[str, Any]: current = moment.astimezone(self.timezone) if moment else self.now() date_digits = current.strftime("%d%m") mask = str(self.settings["daily_mask"]) return { "date": current.strftime("%d.%m.%Y"), "date_digits": date_digits, "mask": mask, "pin": self.daily_pin(current), "timezone": str(self.settings["timezone"]), "steps": [ f"{date_digit} + {mask_digit} → {(int(date_digit) + int(mask_digit)) % 10}" for date_digit, mask_digit in zip(date_digits, mask, strict=True) ], } @staticmethod def _client_key(request: Request) -> str: return request.client.host if request.client else "local" def _cleanup_sessions(self) -> None: now = time.time() for token, expires_at in list(self._sessions.items()): if expires_at <= now: self._sessions.pop(token, None) def is_token_valid(self, token: str | None) -> bool: if not token: return False with self._lock: self._cleanup_sessions() return self._sessions.get(token, 0) > time.time() def is_request_authenticated(self, request: Request) -> bool: return self.is_token_valid(request.cookies.get(COOKIE_NAME)) async def require_editor(self, request: Request) -> None: if not self.is_request_authenticated(request): raise HTTPException(status_code=401, detail="Сессия конструктора не активна") def status(self, request: Request) -> dict[str, Any]: client_key = self._client_key(request) token = request.cookies.get(COOKIE_NAME) now = time.time() with self._lock: self._cleanup_sessions() failure = self._failures.get(client_key, FailureState()) expires_at = self._sessions.get(token or "", 0) authenticated = expires_at > now return { "authenticated": authenticated, "expires_in": max(0, int(expires_at - now)) if authenticated else 0, "attempts_remaining": max( 0, int(self.settings["max_attempts"]) - failure.attempts, ), "locked_for": max(0, int(round(failure.locked_until - now))), "timezone": str(self.settings["timezone"]), "date": self.now().strftime("%d.%m.%Y"), "session_minutes": int(self.settings["session_minutes"]), } def login(self, request: Request, pin: str) -> tuple[str, int]: client_key = self._client_key(request) now = time.time() supplied = str(pin).strip() with self._lock: failure = self._failures.setdefault(client_key, FailureState()) if failure.locked_until > now: raise HTTPException( status_code=429, detail={ "message": "Вход временно заблокирован", "locked_for": max(1, int(round(failure.locked_until - now))), }, ) valid = ( hmac.compare_digest(supplied, self.daily_pin()) or hmac.compare_digest(supplied, str(self.settings["master_pin"])) ) if not valid: failure.attempts += 1 remaining = max( 0, int(self.settings["max_attempts"]) - failure.attempts, ) if failure.attempts >= int(self.settings["max_attempts"]): failure.attempts = 0 failure.locked_until = now + int(self.settings["lock_seconds"]) raise HTTPException( status_code=429, detail={ "message": "Вход временно заблокирован", "locked_for": int(self.settings["lock_seconds"]), }, ) raise HTTPException( status_code=401, detail={ "message": "Неверный PIN", "attempts_remaining": remaining, }, ) self._failures.pop(client_key, None) token = secrets.token_urlsafe(32) self._sessions[token] = now + self.session_seconds return token, self.session_seconds def logout(self, request: Request) -> None: token = request.cookies.get(COOKIE_NAME) if token: with self._lock: self._sessions.pop(token, None) def create_editor_auth_router( auth: EditorAuthManager, *, prefix: str = "/api/ui-builder/auth", editor_url: str = "/editor", ) -> APIRouter: router = APIRouter(prefix=prefix, tags=["UI Builder Auth"]) @router.get("/status") async def auth_status(request: Request) -> dict[str, Any]: return auth.status(request) @router.post("/login") async def auth_login(request: Request, payload: PinPayload) -> JSONResponse: token, max_age = auth.login(request, payload.pin) response = JSONResponse( {"ok": True, "editor_url": editor_url, "expires_in": max_age} ) response.set_cookie( COOKIE_NAME, token, max_age=max_age, httponly=True, samesite="strict", secure=bool(auth.settings["cookie_secure"]), path="/", ) return response @router.post("/logout") async def auth_logout(request: Request) -> JSONResponse: auth.logout(request) response = JSONResponse({"ok": True}) response.delete_cookie(COOKIE_NAME, path="/") return response return router