"""Ochrona dostępu do aplikacji (LOG-32). Rdzeniem produktu są oryginalne bazy interpretacyjne. Aplikacja podaje ich treść na wielu ścieżkach (`/significators`, `/interpret`, generator promptu), więc BRAK uwierzytelnienia oznacza, że każdy w sieci może je wypompować — bez udziału jakiegokolwiek modelu językowego. Ten moduł zamyka tę drogę. Trzy mechanizmy: * **HTTP Basic** — wejście do aplikacji; włącza się, gdy są jakiekolwiek konta. * **limit żądań** — hamuje masowe odpytywanie (eksfiltrację przez pętlę zapytań); rozliczany per adres klienta, a za odwrotnym proxy — po TRUST_PROXY=true — per adres z nagłówka, nie per adres proxy (patrz `client_ip`). * **konta imienne + dziennik audytowy** (PRE-17) — patrz niżej. KONTA IMIENNE (PRE-17). Jedno wspólne hasło nie mówi, KTO sięgał do baz, a odebranie dostępu jednej osobie wymagało zmiany hasła wszystkim. `APP_USERS` trzyma listę kont `login:sekret` — sekret najlepiej jako HASH scrypt (stdlib, bez nowych zależności): `scrypt$$`, patrz scripts/make_user.py. Odebranie dostępu = usunięcie wpisu. Gdy `APP_USERS` jest ustawione, wspólne APP_PASSWORD PRZESTAJE działać — inaczej stare hasło cicho zostawiałoby tylne wejście, czyli dokładnie problem, który to wymaganie zamyka. DZIENNIK AUDYTOWY (PRE-17). Każde żądanie do chronionej ścieżki zostawia wpis: kto, kiedy, jaka ścieżka, status, ile rekordów (gdy handler to poda przez `audit_records`). To jedyny sposób, by zauważyć POWOLNE wypompowywanie bazy przez osobę uprawnioną — pojedyncze zapytanie wygląda niewinnie, suma już nie. Świadomie NIE logujemy treści żądań, promptów ani rekordów — logi to kolejny nośnik wycieku; liczby i metadane wystarczą do wykrycia nadużycia. UWAGA: bez kont ochrona jest WYŁĄCZONA (zgodność wstecz i wygoda dev). Wtedy przy starcie leci głośne ostrzeżenie — żeby nikt nie wdrożył tego w przekonaniu, że jest chroniony. """ from __future__ import annotations import base64 import binascii import hashlib import hmac import logging import os import secrets import time from collections import deque from dataclasses import dataclass from fastapi import Request from fastapi.responses import HTMLResponse, JSONResponse log = logging.getLogger("astrololo.security") MAX_TRACKED_CLIENTS_DEFAULT = 4096 MAX_TRACKED_CLIENTS = MAX_TRACKED_CLIENTS_DEFAULT # zabezpieczenie przed puchnięciem pamięci # konfiguracja czytana leniwie — testy i restart mogą ją zmienić bez importu modułu def app_user() -> str: return os.getenv("APP_USER", "astrololo") def app_password() -> str: return os.getenv("APP_PASSWORD", "") # ── konta imienne (PRE-17) ─────────────────────────────────────────────── # Parametry scrypt: rozsądny kompromis czas/pamięć dla logowania do aplikacji # wewnętrznej. Zmiana wymaga przeliczenia haseł (hash niesie tylko sól). _SCRYPT_N, _SCRYPT_R, _SCRYPT_P, _SCRYPT_LEN = 2**14, 8, 1, 32 _HASH_PREFIX = "scrypt$" def hash_password(password: str, salt: bytes | None = None) -> str: """Hasło → wpis do APP_USERS: `scrypt$$`.""" salt = salt or secrets.token_bytes(16) dk = hashlib.scrypt(password.encode("utf-8"), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=_SCRYPT_LEN) return f"{_HASH_PREFIX}{salt.hex()}${dk.hex()}" def accounts() -> dict[str, str]: """Konta z `APP_USERS` (`login:sekret`, po przecinku). Gdy pusto — zgodność wstecz: pojedyncze konto z APP_USER/APP_PASSWORD.""" out: dict[str, str] = {} for entry in os.getenv("APP_USERS", "").split(","): entry = entry.strip() if not entry: continue user, sep, secret = entry.partition(":") if sep and user.strip() and secret.strip(): out[user.strip()] = secret.strip() if out: return out return {app_user(): app_password()} if app_password() else {} def _secret_matches(spec: str, password: str) -> bool: """Sekret konta vs podane hasło. Hash scrypt albo (zgodność wstecz) jawne.""" if spec.startswith(_HASH_PREFIX): try: _, salt_hex, hash_hex = spec.split("$", 2) salt, expected = bytes.fromhex(salt_hex), bytes.fromhex(hash_hex) except ValueError: return False actual = hashlib.scrypt(password.encode("utf-8"), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=_SCRYPT_LEN) return hmac.compare_digest(actual, expected) # Porównujemy BAJTY, nie tekst: `compare_digest` na stringach rzuca TypeError # przy znakach spoza ASCII, więc hasło z polskimi literami wywracało logowanie # błędem 500 zamiast zwykłej odmowy (błąd zastany, złapany testem). return hmac.compare_digest(spec.encode("utf-8"), password.encode("utf-8")) def rate_limit_per_min() -> int: return int(os.getenv("RATE_LIMIT_PER_MIN", "120")) def trust_proxy() -> bool: return os.getenv("TRUST_PROXY", "").strip().lower() in {"1", "true", "yes", "on"} # /wyloguj jest „publiczna" tylko technicznie: przechodzi obok bramki, żeby móc # odpowiedzieć 401 CELOWO, zamiast dostać 200 od zalogowanej sesji. Bez tego # wylogowanie byłoby niemożliwe — przeglądarka dosyła zapamiętane dane przy każdym # żądaniu, więc handler i tak widziałby zalogowaną osobę. PUBLIC_PATHS = frozenset({"/health", "/wyloguj"}) PUBLIC_PREFIXES = ("/static/",) _hits: dict[str, deque[float]] = {} def auth_enabled() -> bool: return bool(accounts()) def _is_public(path: str) -> bool: return path in PUBLIC_PATHS or path.startswith(PUBLIC_PREFIXES) @dataclass(frozen=True) class Principal: """Kto się zalogował i co wolno mu zobaczyć.""" login: str permissions: frozenset[str] is_admin: bool def _admin_permissions() -> frozenset[str]: """Administrator widzi wszystko — z definicji, bez wpisywania listy ręcznie. Liczone z katalogu, więc dopisanie nowej funkcji NIE wymaga pamiętania o koncie administracyjnym. Przeciwnie byłoby cichą pułapką: nowa funkcja byłaby niewidoczna dla jedynej osoby, która ma ją komu nadać.""" from app import features return frozenset(features.GRANTABLE) | {features.ADMIN} def principal(header: str | None) -> Principal | None: """Nagłówek Basic → kto to jest i co mu wolno; None, gdy dane nie pasują. Kolejność: najpierw konta z KONFIGURACJI ŚRODOWISKA (administracyjne, pełne uprawnienia), potem konta zarządzane z ekranu. Nie odwrotnie — inaczej założenie w pliku konta o loginie administratora przesłoniłoby konto administracyjne i odebrało mu uprawnienia. Nieznany login i złe hasło traktujemy tak samo: odmowa nie mówi, które konto istnieje.""" if not header or not header.lower().startswith("basic "): return None try: raw = base64.b64decode(header.split(" ", 1)[1]).decode("utf-8") user, _, password = raw.partition(":") except (binascii.Error, UnicodeDecodeError, IndexError): return None spec = accounts().get(user) if spec is not None: return (Principal(user, _admin_permissions(), True) if _secret_matches(spec, password) else None) from app import accounts as store managed = store.secret_of(user) if managed is None: return None if not _secret_matches(managed, password): return None return Principal(user, store.permissions_of(user), False) def authenticate(header: str | None) -> str | None: """Sam LOGIN — do dziennika audytowego (PRE-17) i zgodności wstecz.""" who = principal(header) return who.login if who else None def client_ip(request: Request) -> str: """Adres, po którym rozliczamy limit żądań. Za odwrotnym proxy (u nas: Ingress/Traefik po włączeniu TLS — PRE-16) `request.client.host` to adres POD-a proxy, jednakowy dla wszystkich. Bez poprawki cały ruch trafiałby do jednego wiadra i pierwsza osoba, która wyklika limit, odcięłaby pozostałe. Nagłówkom wierzymy WYŁĄCZNIE przy TRUST_PROXY — bo inaczej wystarczyłoby dopisać własny `X-Forwarded-For`, żeby przy każdym żądaniu wyglądać na kogoś innego i ominąć limit całkowicie. Z tego samego powodu bierzemy OSTATNI wpis listy: to jedyny, który dopisał nasz proxy. Wcześniejsze mógł podstawić klient, więc nie znaczą nic. """ peer = request.client.host if request.client else "?" if not trust_proxy(): return peer forwarded = request.headers.get("x-forwarded-for", "") if forwarded: last = forwarded.rsplit(",", 1)[-1].strip() if last: return last return request.headers.get("x-real-ip", "").strip() or peer def _rate_limited(client: str) -> bool: cap = rate_limit_per_min() if cap <= 0: return False now = time.monotonic() window = _hits.get(client) if window is None: if len(_hits) >= MAX_TRACKED_CLIENTS: _hits.clear() # prosty reset zamiast nieograniczonego wzrostu window = _hits[client] = deque() while window and now - window[0] > 60.0: window.popleft() if len(window) >= cap: return True window.append(now) return False # ── dziennik audytowy (PRE-17) ─────────────────────────────────────────── audit_log = logging.getLogger("astrololo.audit") def _setup_audit_logging() -> None: """Gwarantuje, że wpisy audytowe FAKTYCZNIE wychodzą na stdout. Domyślna konfiguracja uvicorna nie obsługuje naszych loggerów, więc wpisy na poziomie INFO ginęły — dziennik istniał w kodzie, ale był pusty (sprawdzone na żywym serwerze). Niewidoczny dziennik jest gorszy niż jego brak: daje złudzenie kontroli. Własny handler na stdout, bo w k8s to stamtąd zbierane są logi. `propagate=False` — żeby wpis nie dublował się przez korzeń. """ audit_log.setLevel(os.getenv("AUDIT_LEVEL", "INFO").upper()) if not audit_log.handlers: handler = logging.StreamHandler() handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s")) audit_log.addHandler(handler) audit_log.propagate = False def audit_records(request: Request, count: int) -> None: """Handler zgłasza, ILE rekordów baz oddał — trafi do wpisu audytowego. Sama liczba, nigdy treść. Bez tego dziennik pokazywałby tylko „ktoś wchodził", a nie „ktoś wyniósł 5000 rekordów" — a to drugie jest sygnałem nadużycia.""" try: request.state.audit_records = int(count) except (TypeError, ValueError): pass def _audit(request: Request, user: str, status: int, ms: float) -> None: records = getattr(request.state, "audit_records", None) audit_log.info( "AUDYT user=%s ip=%s method=%s path=%s status=%s records=%s ms=%.0f", user or "-", client_ip(request), request.method, request.url.path, status, "-" if records is None else records, ms, ) def install(app) -> None: """Podpina ochronę pod wszystkie ścieżki poza /health i /static.""" _setup_audit_logging() if not auth_enabled(): log.warning( "UWAGA: brak kont (APP_USERS / APP_PASSWORD) — aplikacja jest OTWARTA " "dla każdego, kto ma do niej dostęp sieciowy, wraz z treścią baz " "interpretacyjnych." ) elif os.getenv("APP_USERS", "").strip() and app_password(): # Gdyby wspólne hasło działało obok kont imiennych, zostawiałoby tylne # wejście bez śladu w dzienniku — czyli problem, który PRE-17 zamyka. log.warning( "APP_USERS jest ustawione, więc wspólne APP_PASSWORD NIE działa " "(konta imienne mają pierwszeństwo). Usuń APP_PASSWORD z konfiguracji." ) @app.middleware("http") async def _guard(request: Request, call_next): if _is_public(request.url.path): return await call_next(request) started = time.monotonic() if _rate_limited(client_ip(request)): _audit(request, "-", 429, (time.monotonic() - started) * 1000) return JSONResponse( {"detail": "Zbyt wiele żądań — spróbuj za chwilę."}, status_code=429, headers={"Retry-After": "60"}, ) from app import features user, who = "", None if auth_enabled(): who = principal(request.headers.get("authorization")) if who is None: _audit(request, "-", 401, (time.monotonic() - started) * 1000) return HTMLResponse( "

401 — wymagane logowanie

", status_code=401, headers={"WWW-Authenticate": 'Basic realm="astrololo"'}, ) user = who.login else: # Bez kont ochrona jest wyłączona (dev), więc widać wszystko — inaczej # deweloper zobaczyłby pustą aplikację i szukałby błędu tam, gdzie go nie ma. who = Principal("", _admin_permissions(), True) request.state.user = user request.state.principal = who # 404, NIE 403. Odmowa z powodem jest informacją, że coś tam jest — # a konto ograniczone ma widzieć program kompletny, tylko mniejszy. need = features.required(request.method, request.url.path) if not features.can(who.permissions, need): _audit(request, user, 404, (time.monotonic() - started) * 1000) return HTMLResponse("

404 — nie znaleziono

", status_code=404) response = await call_next(request) _audit(request, user, response.status_code, (time.monotonic() - started) * 1000) return response