f0d07ee8c3
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m17s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m30s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 5s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 4s
build / build (push) Successful in 7s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m18s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 6s
Testy / Kontrola składni wszystkich warstw (push) Successful in 4s
Basic nie miał wylogowania i nie dało się tego obejść: przeglądarka zapamiętuje
hasło i dosyła je SAMA przy każdym żądaniu, więc serwer nie ma czego zapomnieć.
Poprzednia próba (LOG-32) opierała się na nakłonieniu przeglądarki, żeby porzuciła
zapamiętane dane — zachowaniu powszechnym, ale nigdzie nie zapisanym. Teraz to
serwer decyduje, czy dana przeglądarka jest w środku, i może to cofnąć.
TRZY POZIOMY UNIEWAŻNIENIA, celowo rozdzielone, bo każdy kosztuje co innego:
1. wylogowanie = skasowanie ciasteczka. Natychmiastowe, bez magazynu.
2. zmiana hasła albo skasowanie konta = odcisk poświadczenia wpisany
w ciasteczko przestaje pasować. Dzieje się SAMO, bez pamiętania o tym.
Bez tego odebranie komuś dostępu nie odbierałoby dostępu aż do wygaśnięcia.
3. „zamknij sesje" z ekranu kont = licznik pokolenia. Jedyny wymagający zapisu,
więc jedyny opcjonalny: gdy licznika nie ma, poziomy 1 i 2 nadal działają.
KONTO ADMINISTRACYJNE ODSEPAROWANE. Sprawdzane pierwsze i BEZ DOTYKANIA pliku
kont, co daje dwie rzeczy naraz: konto z pliku o tym samym loginie nie przesłoni
administratora, a administrator zaloguje się także wtedy, gdy plik jest uszkodzony
— czyli w jedynej sytuacji, w której ktoś MUSI wejść, żeby to naprawić. Trzymanie
jego stanu w tym samym pliku dawałoby zakleszczenie: nie da się naprawić, bo nie
da się wejść. Jego odpowiednikiem „wyloguj zewsząd" jest zmiana APP_PASSWORD.
KLUCZ WYMAGANY, FAIL-CLOSED. Usługa z kontami, ale bez klucza podpisu, nie
odróżniłaby ważnej sesji od podrobionej, więc nie wstaje — i lepiej przy starcie
niż przy pierwszym logowaniu człowieka. Losowanie klucza byłoby wygodne, ale
wylogowywałoby wszystkich przy każdym restarcie poda: wygląda jak awaria i uczy
ludzi ignorować ekran logowania.
Ciasteczko HttpOnly (jeden wstrzyknięty skrypt inaczej wynosi sesję) i
SameSite=Strict (obca strona nie zadziała w imieniu zalogowanego). Wylogowanie
POST-em, nie odsyłaczem: pod adresem GET wystarczyłby obrazek na obcej stronie.
Adres powrotu po zalogowaniu przechodzi przez sito — bez tego `?dokad=https://obcy`
zamieniłby nasz ekran logowania w narzędzie do wyłudzania haseł.
Kres bezczynności 8 h i twardy 30 dni. Znacznik aktywności odświeżany z progiem,
inaczej Set-Cookie leciałby przy każdym obrazku i arkuszu stylów.
Zdarzenia logowania w dzienniku (PRE-17). Nieudane próby są tam ważniejsze od
udanych: pojedyncza nic nie znaczy, ale seria pod jednym adresem to jedyny
widoczny ślad zgadywania haseł. Test pilnuje, że hasło tam nie trafia.
Sprawdzone w przeglądarce: po zalogowaniu ciasteczko jest NIEWIDOCZNE dla
JavaScriptu, a po wylogowaniu wejście na chronioną stronę ląduje na ekranie
logowania — czyli dokładnie to, czego Basic nie potrafił.
Testy: 21 na rdzeń podpisywania (w tym podrabianie ładunku, podpisu i klucza,
ciasteczko z przyszłości, śmieci na wejściu), reszta przepisana z Basic na sesje.
Prezentacja 338 zielonych.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
442 lines
18 KiB
Python
442 lines
18 KiB
Python
"""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$<sól_hex>$<hash_hex>`, 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, RedirectResponse
|
|
|
|
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$<sól_hex>$<hash_hex>`."""
|
|
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 cookies_secure() -> bool:
|
|
"""Czy ciasteczko sesji ma być wyłącznie dla https.
|
|
|
|
Domyślnie TAK. Wyłączamy jedynie w pracy lokalnej po http — inaczej
|
|
przeglądarka po prostu nie zapamięta ciasteczka i logowanie „nie działa"
|
|
bez żadnego komunikatu, co jest wyjątkowo mylącą awarią."""
|
|
return os.getenv("COOKIES_INSECURE", "").strip().lower() not in {"1", "true", "yes", "on"}
|
|
|
|
|
|
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ę.
|
|
# /logowanie MUSI stać poza bramką — inaczej żeby się zalogować, trzeba by być
|
|
# zalogowanym. /wyloguj też: ma działać także wtedy, gdy sesja już wygasła.
|
|
PUBLIC_PATHS = frozenset({"/health", "/logowanie", "/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 wypisywania listy ręcznie.
|
|
|
|
Liczone z katalogu, więc dopisanie 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 _admin_spec(login: str) -> str | None:
|
|
"""Sekret konta administracyjnego z KONFIGURACJI ŚRODOWISKA albo None.
|
|
|
|
Sprawdzane ZAWSZE PIERWSZE i bez dotykania pliku kont — to jest cała
|
|
separacja konta administracyjnego (LOG-34):
|
|
|
|
* konto z pliku o tym samym loginie nie przesłoni administratora, bo do
|
|
pliku w ogóle nie zaglądamy, gdy login pasuje do konfiguracji,
|
|
* administrator zaloguje się nawet wtedy, gdy plik kont jest uszkodzony
|
|
albo nieczytelny — czyli w jedynej sytuacji, w której ktoś musi wejść,
|
|
żeby to naprawić. Trzymanie jego stanu w tym samym pliku dawałoby
|
|
zakleszczenie: nie da się naprawić, bo nie da się wejść.
|
|
"""
|
|
return accounts().get(login)
|
|
|
|
|
|
def verify(login: str, password: str) -> Principal | None:
|
|
"""Sprawdza login i hasło. None = odmowa, bez podawania powodu.
|
|
|
|
Nieznany login i złe hasło dają ten sam wynik: rozróżnienie mówiłoby, które
|
|
konta istnieją, a to informacja warta czegoś dla zgadującego."""
|
|
spec = _admin_spec(login)
|
|
if spec is not None:
|
|
return Principal(login, _admin_permissions(), True) if _secret_matches(spec, password) else None
|
|
|
|
from app import accounts as store
|
|
|
|
try:
|
|
zapisany = store.secret_of(login)
|
|
except store.AccountsUnavailable:
|
|
# Magazyn kont niedostępny — konta zarządzane nie mogą się zalogować,
|
|
# ale administrator (wyżej) już przeszedł, więc jest kto to naprawi.
|
|
return None
|
|
if zapisany is None or not _secret_matches(zapisany, password):
|
|
return None
|
|
return Principal(login, store.permissions_of(login), False)
|
|
|
|
|
|
def issue_session(login: str) -> str:
|
|
"""Zawartość ciasteczka dla właśnie zalogowanego konta.
|
|
|
|
W ciasteczku ląduje ODCISK poświadczenia, dzięki czemu zmiana hasła albo
|
|
skasowanie konta unieważnia jego sesje same z siebie — bez pamiętania o tym
|
|
i bez żadnego magazynu sesji."""
|
|
from app import accounts as store
|
|
from app import session
|
|
|
|
spec = _admin_spec(login)
|
|
if spec is not None:
|
|
return session.issue(login, session.fingerprint(spec), 0)
|
|
return session.issue(login, session.fingerprint(store.secret_of(login) or ""),
|
|
store.session_version(login))
|
|
|
|
|
|
def principal(request: Request) -> Principal | None:
|
|
"""Ciasteczko → kto to jest i co mu wolno. None = nie wpuszczać."""
|
|
from app import session
|
|
|
|
dane = session.read(request.cookies.get(session.COOKIE))
|
|
if dane is None:
|
|
return None
|
|
login = dane["login"]
|
|
|
|
spec = _admin_spec(login)
|
|
if spec is not None:
|
|
# Zmiana APP_PASSWORD ubija sesje administratora — to jest jego
|
|
# odpowiednik „wyloguj mnie ze wszystkich urządzeń".
|
|
if session.fingerprint(spec) != dane["odcisk"]:
|
|
return None
|
|
return Principal(login, _admin_permissions(), True)
|
|
|
|
from app import accounts as store
|
|
|
|
try:
|
|
zapisany = store.secret_of(login)
|
|
wersja = store.session_version(login)
|
|
except store.AccountsUnavailable:
|
|
return None
|
|
if zapisany is None or session.fingerprint(zapisany) != dane["odcisk"]:
|
|
return None
|
|
if wersja != dane["wersja"]:
|
|
return None
|
|
return Principal(login, store.permissions_of(login), False)
|
|
|
|
|
|
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_login(request: Request, login: str, udane: bool) -> None:
|
|
"""Zdarzenie logowania w dzienniku (PRE-17).
|
|
|
|
Nieudane próby są tu ważniejsze od udanych: pojedyncza nic nie znaczy, ale
|
|
seria pod jednym adresem to jedyny widoczny ślad zgadywania haseł. Logujemy
|
|
LOGIN, nigdy hasła ani jego fragmentu — dziennik to kolejny nośnik wycieku."""
|
|
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 _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 publicznymi."""
|
|
from app import session
|
|
|
|
_setup_audit_logging()
|
|
|
|
# FAIL-CLOSED. Usługa z kontami, ale bez klucza podpisu, nie umiałaby odróżnić
|
|
# ważnej sesji od podrobionej. Lepiej, żeby nie wstała, niż żeby wpuszczała —
|
|
# i lepiej teraz, przy starcie, niż przy pierwszym logowaniu człowieka.
|
|
if auth_enabled() and not session.available():
|
|
raise session.SessionError(
|
|
"Konta są skonfigurowane, ale brakuje SESSION_SECRET — bez klucza "
|
|
"podpisu sesje nie działają, a logowanie nie miałoby czym się bronić. "
|
|
"Wygeneruj: openssl rand -hex 32")
|
|
|
|
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, odswiez = "", None, None
|
|
if auth_enabled():
|
|
who = principal(request)
|
|
if who is None:
|
|
_audit(request, "-", 303, (time.monotonic() - started) * 1000)
|
|
# Przekierowanie na ekran logowania, nie 401: 401 z Basic otwierało
|
|
# okienko systemowe, którego nie da się ani opisać, ani z niego wyjść.
|
|
# Zapamiętujemy, dokąd ktoś zmierzał — po zalogowaniu wraca na miejsce.
|
|
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)
|
|
user = who.login
|
|
# Ruch przedłuża sesję, ale ciasteczko odsyłamy tylko wtedy, gdy
|
|
# znacznik zdążył się zestarzeć — inaczej Set-Cookie leciałby przy
|
|
# każdym obrazku i arkuszu stylów.
|
|
from app import session as _s
|
|
|
|
dane = _s.read(request.cookies.get(_s.COOKIE))
|
|
odswiez = _s.refresh(dane) if dane else None
|
|
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("<h1>404 — nie znaleziono</h1>", status_code=404)
|
|
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()))
|
|
_audit(request, user, response.status_code, (time.monotonic() - started) * 1000)
|
|
return response
|