caf4fd80d1
Demo ma być rozdawane szeroko i różnym osobom, więc pierwsza wersja — jedno konto na produkcyjnej warstwie danych — nie nadawała się do użycia: każdy dostawałby dostęp do oryginalnych baz, a wgrania jednego klienta widzieliby wszyscy. IZOLACJA OD PRODUKCJI. Warstwa danych i logiczna demo są osobne (manifesty w repo deploy). Osobna musi być TEŻ LOGICZNA, bo zna ona jeden adres warstwy danych — demo korzystające z produkcyjnej logiki i tak trafiłoby na produkcyjne bazy. PULE PER KONTO w warstwie danych. Zapytanie i lista plików niosą nazwę puli; puste = cały udział, czyli produkcja działa dokładnie jak dotąd i o pulach nic nie wie. Nazwa puli przechodzi przez sito dopuszczające wyłącznie znaki bezpieczne w nazwie katalogu — „../..” albo ukośnik wyprowadziłyby zapytanie wprost do cudzych baz, więc sito ZAMIENIA podejrzane znaki zamiast ufać, że nikt ich nie poda. PULA MUSI BYĆ W KLUCZU CACHE ZAPYTAŃ. Bez tego wynik policzony dla jednego konta trafiłby z cache do drugiego — cicha wymiana treści baz między klientami, niewidoczna w logach i nie do wykrycia z zewnątrz. Osobny test tego pilnuje. PULA WYNIKA Z LOGINU, nigdy z żądania. Klient warstwy logicznej jest budowany per żądanie i związany z pulą zalogowanej osoby; gdyby nazwa przychodziła z formularza, wystarczyłoby podstawić cudzy login. Test wysyła `tenant`, `user` i `login` w polach formularza i sprawdza, że nie mają na nią wpływu. Pulę wstrzykujemy w INSTANCJĘ klienta, nie w sygnatury metod. Argumentem trzeba by ją przeprowadzić przez protokół DataSource i build_report — kod, który o kontach nie ma prawa nic wiedzieć — a każde nowe wywołanie byłoby okazją, żeby o nią zapomnieć i sięgnąć nie tam. Konta demo to lista `login:sekret` (DEMO_USERS), bo jedno wspólne konto oznaczałoby wspólną pulę. Format i skrypt haseł te same, co w głównej aplikacji. Pula klienta to JEDEN KATALOG, więc przejście na pełną wersję nie oznacza utraty wgrań — procedurę importu opisuje runbook w repo deploy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
198 lines
7.2 KiB
Python
198 lines
7.2 KiB
Python
"""Logowanie do astroklienta (PRE-28).
|
|
|
|
OSOBNE KONTO, NIE WSPÓŁDZIELONE Z GŁÓWNĄ APLIKACJĄ. Demo pracuje na tej samej
|
|
warstwie danych co produkcja, więc kto ma do niego dostęp, czyta oryginalne bazy
|
|
interpretacyjne. Własny login i hasło pozwalają odciąć demo jedną zmienną
|
|
środowiskową, bez ruszania kont głównej aplikacji i bez zmiany hasła komukolwiek.
|
|
|
|
Mechanizm jest CELOWO prosty: jedno konto z konfiguracji, bez ekranu kont
|
|
i bez uprawnień. Astroklient ma dwie funkcje i obie są dla tego samego człowieka —
|
|
model uprawnień z PRE-27 nie miałby tu czego dzielić.
|
|
|
|
Hasło może być jawne albo jako hash scrypt (`scrypt$sól$hash`), tym samym formatem
|
|
co APP_USERS w głównej aplikacji — dzięki temu skrypt make_user.py działa na oba.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import binascii
|
|
import hashlib
|
|
import hmac
|
|
import logging
|
|
import os
|
|
import time
|
|
from collections import deque
|
|
|
|
from fastapi import Request
|
|
from fastapi.responses import HTMLResponse, JSONResponse
|
|
|
|
log = logging.getLogger("astroklient.security")
|
|
audit_log = logging.getLogger("astroklient.audit")
|
|
|
|
_SCRYPT_N, _SCRYPT_R, _SCRYPT_P, _SCRYPT_LEN = 2**14, 8, 1, 32
|
|
_HASH_PREFIX = "scrypt$"
|
|
|
|
PUBLIC_PATHS = frozenset({"/health"})
|
|
PUBLIC_PREFIXES = ("/static/",)
|
|
|
|
_hits: dict[str, deque[float]] = {}
|
|
MAX_TRACKED_CLIENTS = 4096
|
|
|
|
|
|
def accounts() -> dict[str, str]:
|
|
"""Konta demo: `login:sekret`, po przecinku, ze zmiennej DEMO_USERS.
|
|
|
|
WIELE KONT, bo każde ma WŁASNĄ, niewidoczną dla pozostałych pulę baz (PRE-29).
|
|
Wspólne konto oznaczałoby wspólną pulę, czyli klientów oglądających nawzajem
|
|
swoje wgrania.
|
|
|
|
Sekret najlepiej jako hash scrypt (`scrypt$sól$hash`) — ten sam format i ten
|
|
sam skrypt make_user.py, co w głównej aplikacji.
|
|
|
|
Zgodność wstecz: pojedyncze DEMO_USER/DEMO_PASSWORD działa, gdy DEMO_USERS
|
|
jest puste."""
|
|
out: dict[str, str] = {}
|
|
for entry in os.getenv("DEMO_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
|
|
single = os.getenv("DEMO_PASSWORD", "")
|
|
return {os.getenv("DEMO_USER", "demo"): single} if single else {}
|
|
|
|
|
|
def rate_limit_per_min() -> int:
|
|
return int(os.getenv("RATE_LIMIT_PER_MIN", "60"))
|
|
|
|
|
|
def trust_proxy() -> bool:
|
|
return os.getenv("TRUST_PROXY", "").strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
def auth_enabled() -> bool:
|
|
return bool(accounts())
|
|
|
|
|
|
def _secret_matches(spec: str, password: str) -> bool:
|
|
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)
|
|
# BAJTY, nie tekst: compare_digest na stringach rzuca TypeError przy znakach
|
|
# spoza ASCII, więc hasło z polskimi literami dawałoby 500 zamiast odmowy.
|
|
return hmac.compare_digest(spec.encode("utf-8"), password.encode("utf-8"))
|
|
|
|
|
|
def authenticate(header: str | None) -> str | None:
|
|
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 None:
|
|
return None
|
|
# Nieznany login i złe hasło traktujemy tak samo — odmowa nie mówi, które
|
|
# konto istnieje, a przy demo rozdawanym szeroko to realna informacja.
|
|
return user if _secret_matches(spec, password) else None
|
|
|
|
|
|
def client_ip(request: Request) -> str:
|
|
"""Adres do rozliczania limitu. Nagłówkom wierzymy WYŁĄCZNIE za proxy —
|
|
inaczej wystarczyłoby podstawić własny X-Forwarded-For, żeby ominąć limit."""
|
|
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()
|
|
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
|
|
|
|
|
|
def _setup_audit_logging() -> None:
|
|
"""Własny handler na stdout. Domyślna konfiguracja uvicorna nie obsługuje
|
|
naszych loggerów, więc wpisy INFO ginęły — dziennik istniał w kodzie, ale był
|
|
pusty. Niewidoczny dziennik jest gorszy niż jego brak (błąd zastany w PRE-17)."""
|
|
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:
|
|
try:
|
|
request.state.audit_records = int(count)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
|
|
|
|
def install(app) -> None:
|
|
_setup_audit_logging()
|
|
if not auth_enabled():
|
|
log.warning(
|
|
"UWAGA: brak kont (DEMO_USERS / DEMO_PASSWORD) — astroklient stoi OTWARTY. "
|
|
"Bez logowania nie ma też PULI: wszyscy pracowaliby na jednym, wspólnym "
|
|
"zbiorze baz, widząc nawzajem swoje wgrania."
|
|
)
|
|
|
|
@app.middleware("http")
|
|
async def _guard(request: Request, call_next):
|
|
if request.url.path in PUBLIC_PATHS or request.url.path.startswith(PUBLIC_PREFIXES):
|
|
return await call_next(request)
|
|
|
|
started = time.monotonic()
|
|
if _rate_limited(client_ip(request)):
|
|
return JSONResponse({"detail": "Zbyt wiele żądań — spróbuj za chwilę."},
|
|
status_code=429, headers={"Retry-After": "60"})
|
|
|
|
user = ""
|
|
if auth_enabled():
|
|
user = authenticate(request.headers.get("authorization")) or ""
|
|
if not user:
|
|
return HTMLResponse(
|
|
"<h1>401 — wymagane logowanie</h1>", status_code=401,
|
|
headers={"WWW-Authenticate": 'Basic realm="astroklient"'})
|
|
request.state.user = user
|
|
|
|
response = await call_next(request)
|
|
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,
|
|
response.status_code, "-" if records is None else records,
|
|
(time.monotonic() - started) * 1000)
|
|
return response
|