2e9d3706ec
Testy / Testy warstwy logicznej (silnik) (push) Failing after 4m50s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m28s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m25s
Testy / Testy astrodemo (push) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 8s
Testy / Kontrola składni wszystkich warstw (push) Successful in 5s
Testy / Testy warstwy logicznej (silnik) (pull_request) Failing after 4m43s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m28s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m25s
Testy / Testy astrodemo (pull_request) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 6s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 4s
Pierwszy z pięciu kroków budowy trzech produktów: astrodemo (dwie funkcje) → astroklient (pełne astro bez AI) → astrololo (wszystko). Nazwa „astroklient" zostaje zwolniona dla warstwy pośredniej, więc dotychczasowe astroklient-demo nazywa się teraz astrodemo. REBASE. Cztery commity demo przeniesione na aktualnego mastera. Konflikt był jeden — rejestr wymagań (xlsx, binarny, git go nie scali). Master dodał LOG-34, gałąź demo PRE-28 i PRE-29; żaden wspólny wiersz się nie różnił, więc scalone ręcznie: 148 pozycji, wszystkie trzy obecne. ZMIANA NAZWY. Katalog, ciasteczko sesji (astrodemo_sesja), zmienne ASTRODEMO_USERS/USER/PASSWORD, CI, README, docstringi. Ponieważ PR z demo nigdy nie został zmergowany, usługa nie jest nigdzie wdrożona — zmiana nazw niczego nie migruje i nikogo nie wylogowuje. WYCIEKI. astrodemo powstało przed audytem z PRE-27, więc miało komplet tych samych dziur: - /static omijało bramkę (PUBLIC_PREFIXES), a pierwszy komentarz w styles.css brzmiał „Nie kopiujemy stylów pełnej aplikacji" — czyli anonimowy curl dowiadywał się, że istnieje pełna aplikacja. Zasoby idą teraz trasą z jawną listą, komentarze są zdejmowane przy serwowaniu. - Komunikat awarii wypisywał na ekran treść wyjątku httpx, z nazwą usługi i portem. Teraz jedno neutralne zdanie, szczegóły do dziennika. - /health oddawał nazwę warstwy. Teraz samo „ok". - Dziesięć komentarzy i docstringów tłumaczyło decyzje przez porównanie z „pełną aplikacją". Obraz tej usługi się KOMUŚ ODDAJE, więc kto go dostanie, przeczyta też komentarze. Przepisane tak, żeby opisywały tę usługę samą w sobie. - Nagłówek main.py twierdził, że demo dzieli pulę plików z produkcją. To nieprawda od PRE-29 (pule per konto) — opis poprawiony. ZAPORA SŁOWNIKOWA, dwupoziomowa. Poziom „wszędzie" (także w kodzie serwera, bo obraz się oddaje) obejmuje wzmianki o większym rodzeństwie, o modelu językowym i o funkcjach, których tu nie ma. Poziom „do przeglądarki" dokłada słownictwo mechanizmów. Test sprawdza odpowiedzi ORAZ drzewo plików. Jeden wyjątek jest jawny i opisany: stałe protokołu łącza (X-Astrololo-Token, X-Astrololo-Enc, typ treści, etykieta HKDF) niosą nazwę rodziny produktów. Są wspólne z warstwą logiczną, więc zmiana wymaga jednoczesnej podmiany we wszystkich usługach i rotacji — osobna decyzja. Osobny test pilnuje warunku, pod jakim to zostaje: że nie docierają do przeglądarki. Wcześniej przechodziły tylko dlatego, że regex nie dopasowywał po myślniku — przypadek, nie decyzja. Przy okazji: komunikat „ramka bez znacznika astrololo" zmieniony na neutralny we WSZYSTKICH PIĘCIU kopiach link_crypto.py (presentation, astrodemo, logic, data, render), żeby nie rozjechały się przed scaleniem w rdzeń. Te kopie to 2625 linii tego samego kodu. Testy: astrodemo 27, presentation 358, logic 342, data 42, render 41. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
267 lines
10 KiB
Python
267 lines
10 KiB
Python
"""Logowanie do astrodemo (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ń. Ta usługa 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, RedirectResponse
|
|
|
|
log = logging.getLogger("astrodemo.security")
|
|
audit_log = logging.getLogger("astrodemo.audit")
|
|
|
|
_SCRYPT_N, _SCRYPT_R, _SCRYPT_P, _SCRYPT_LEN = 2**14, 8, 1, 32
|
|
_HASH_PREFIX = "scrypt$"
|
|
|
|
# /logowanie i /wyloguj poza bramką: inaczej żeby się zalogować, trzeba by być
|
|
# zalogowanym, a wyjście nie działałoby po wygaśnięciu sesji.
|
|
PUBLIC_PATHS = frozenset({"/health", "/logowanie", "/wyloguj", "/static/styles.css"})
|
|
# Pusto CELOWO. Prefiks "/static/" przepuszczał CAŁY katalog obok bramki: każdy
|
|
# plik pobierał ktokolwiek, także niezalogowany, i nie zostawiało to śladu ani
|
|
# w dzienniku, ani w limicie żądań. Publiczny jest jeden arkusz, wymieniony
|
|
# z nazwy, bo potrzebuje go ekran logowania.
|
|
PUBLIC_PREFIXES: tuple[str, ...] = ()
|
|
|
|
_hits: dict[str, deque[float]] = {}
|
|
MAX_TRACKED_CLIENTS = 4096
|
|
|
|
|
|
def accounts() -> dict[str, str]:
|
|
"""Konta demo: `login:sekret`, po przecinku, ze zmiennej ASTRODEMO_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 ASTRODEMO_USER/ASTRODEMO_PASSWORD działa, gdy ASTRODEMO_USERS
|
|
jest puste."""
|
|
out: dict[str, str] = {}
|
|
for entry in os.getenv("ASTRODEMO_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("ASTRODEMO_PASSWORD", "")
|
|
return {os.getenv("ASTRODEMO_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 verify(login: str, password: str) -> str | None:
|
|
"""Sprawdza login i hasło; zwraca login albo 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."""
|
|
spec = accounts().get(login)
|
|
if spec is None:
|
|
return None
|
|
return login if _secret_matches(spec, password) else None
|
|
|
|
|
|
def issue_session(login: str) -> str:
|
|
"""Zawartość ciasteczka dla właśnie zalogowanego konta.
|
|
|
|
W ciasteczku ląduje ODCISK poświadczenia, więc zmiana hasła albo usunięcie
|
|
konta z ASTRODEMO_USERS unieważnia jego sesje samo z siebie. To jest tu jedyny
|
|
sposób zdalnego unieważnienia: demo nie ma gdzie trwale zapisywać stanu,
|
|
bo nie ma własnego wolumenu — a stan sesji na udziale z bazami klientów
|
|
byłby pomieszaniem dwóch zupełnie różnych rzeczy."""
|
|
from app import session
|
|
|
|
return session.issue(login, session.fingerprint(accounts().get(login) or ""), 0)
|
|
|
|
|
|
def principal(request: Request) -> str | None:
|
|
"""Ciasteczko → login zalogowanej osoby albo None."""
|
|
from app import session
|
|
|
|
dane = session.read(request.cookies.get(session.COOKIE))
|
|
if dane is None:
|
|
return None
|
|
spec = accounts().get(dane["login"])
|
|
if spec is None or session.fingerprint(spec) != dane["odcisk"]:
|
|
return None
|
|
return dane["login"]
|
|
|
|
|
|
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 cookies_secure() -> bool:
|
|
"""Ciasteczko tylko dla https. Wyłączane wyłącznie w pracy lokalnej po http —
|
|
inaczej przeglądarka po prostu go nie zapamięta i logowanie „nie działa"
|
|
bez żadnego komunikatu."""
|
|
return os.getenv("COOKIES_INSECURE", "").strip().lower() not in {"1", "true", "yes", "on"}
|
|
|
|
|
|
def audit_login(request: Request, login: str, udane: bool) -> None:
|
|
"""Zdarzenie logowania. Nieudane próby są ważniejsze od udanych: pojedyncza
|
|
nic nie znaczy, seria pod jednym adresem to ślad zgadywania haseł. Login tak,
|
|
hasło NIGDY."""
|
|
audit_log.info("AUDYT zdarzenie=%s user=%s ip=%s",
|
|
"logowanie-ok" if udane else "logowanie-odmowa",
|
|
login or "-", client_ip(request))
|
|
|
|
|
|
def audit_logout(request: Request) -> None:
|
|
audit_log.info("AUDYT zdarzenie=wylogowanie user=%s ip=%s",
|
|
getattr(request.state, "user", "") or "-", client_ip(request))
|
|
|
|
|
|
def install(app) -> None:
|
|
from app import session
|
|
|
|
_setup_audit_logging()
|
|
|
|
# Fail-closed: konta bez klucza podpisu to sesje nie do odróżnienia od
|
|
# podrobionych. Lepiej, żeby usługa nie wstała.
|
|
if auth_enabled() and not session.available():
|
|
raise session.SessionError(
|
|
"Konta demo są skonfigurowane, ale brakuje SESSION_SECRET — bez klucza "
|
|
"podpisu logowanie nie miałoby czym się bronić. openssl rand -hex 32")
|
|
|
|
if not auth_enabled():
|
|
log.warning(
|
|
"UWAGA: brak kont (ASTRODEMO_USERS / ASTRODEMO_PASSWORD) — astrodemo 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, odswiez = "", None
|
|
if auth_enabled():
|
|
user = principal(request) or ""
|
|
if not user:
|
|
from urllib.parse import quote
|
|
|
|
cel = request.url.path
|
|
dokad = f"?dokad={quote(cel, safe='/')}" if request.method == "GET" and cel != "/" else ""
|
|
return RedirectResponse(f"/logowanie{dokad}", status_code=303)
|
|
from app import session as _s
|
|
|
|
dane = _s.read(request.cookies.get(_s.COOKIE))
|
|
odswiez = _s.refresh(dane) if dane else None
|
|
request.state.user = user
|
|
|
|
response = await call_next(request)
|
|
if odswiez:
|
|
from app import session as _s
|
|
|
|
response.set_cookie(_s.COOKIE, odswiez, max_age=_s.max_age(),
|
|
**_s.cookie_params(secure=cookies_secure()))
|
|
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
|