feat(bezpieczeństwo): sesje logowania zamiast HTTP Basic (LOG-34)
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
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>
This commit was merged in pull request #76.
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
"""Logowanie do aplikacji i limit żądań (LOG-32).
|
||||
"""Logowanie do aplikacji i limit żądań (LOG-32/LOG-34).
|
||||
|
||||
To jest brama chroniąca oryginalne bazy interpretacyjne — bez niej każdy w sieci
|
||||
mógł je wypompować przez `/significators` czy generator promptu. Testy pilnują,
|
||||
że brama faktycznie zamyka, a nie tylko wygląda na zamkniętą.
|
||||
"""
|
||||
import base64
|
||||
|
||||
Od LOG-34 bramą jest SESJA, nie HTTP Basic. Zmiana nie jest kosmetyczna: Basic
|
||||
nie miał wylogowania, bo przeglądarka dosyłała hasło sama przy każdym żądaniu.
|
||||
Teraz to serwer decyduje, czy dana przeglądarka jest w środku — i może to cofnąć.
|
||||
"""
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import HTMLResponse
|
||||
@@ -16,9 +18,11 @@ from app import security
|
||||
USER, PASSWORD = "astrololo", "haslo-testowe"
|
||||
|
||||
|
||||
def _basic(user: str, password: str) -> dict[str, str]:
|
||||
raw = base64.b64encode(f"{user}:{password}".encode()).decode()
|
||||
return {"Authorization": f"Basic {raw}"}
|
||||
def _sesja(user: str = USER) -> dict[str, str]:
|
||||
"""Nagłówek z ważną sesją — odpowiednik bycia zalogowanym."""
|
||||
from app import session
|
||||
|
||||
return {"Cookie": f"{session.COOKIE}={security.issue_session(user)}"}
|
||||
|
||||
|
||||
def _app() -> FastAPI:
|
||||
@@ -47,48 +51,93 @@ def _reset_rate_limit():
|
||||
def guarded(monkeypatch):
|
||||
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
|
||||
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "120")
|
||||
return TestClient(_app())
|
||||
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
|
||||
monkeypatch.setenv("COOKIES_INSECURE", "1") # TestClient jedzie po http
|
||||
return TestClient(_app(), follow_redirects=False)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ logowanie
|
||||
|
||||
def test_blocks_anonymous_access(guarded):
|
||||
r = guarded.get("/significators")
|
||||
assert r.status_code == 401
|
||||
assert r.status_code == 303
|
||||
assert "treść z bazy" not in r.text
|
||||
|
||||
|
||||
def test_challenges_with_basic_realm(guarded):
|
||||
"""Bez nagłówka WWW-Authenticate przeglądarka nie pokaże okna logowania."""
|
||||
assert "Basic" in guarded.get("/significators").headers.get("WWW-Authenticate", "")
|
||||
def test_sends_people_to_the_login_screen_not_a_system_dialog(guarded):
|
||||
"""Basic odpowiadał 401 z WWW-Authenticate, co otwierało okienko systemowe:
|
||||
nie dało się go opisać, ostylować ani z niego wyjść inaczej niż anulowaniem.
|
||||
Ekran logowania jest zwykłą stroną, więc może wyjaśnić, co się stało."""
|
||||
r = guarded.get("/significators")
|
||||
assert r.headers["location"].startswith("/logowanie")
|
||||
assert "WWW-Authenticate" not in r.headers, "okienko systemowe wróciło"
|
||||
|
||||
|
||||
def test_rejects_wrong_password(guarded):
|
||||
assert guarded.get("/significators", headers=_basic(USER, "zle")).status_code == 401
|
||||
def test_remembers_where_someone_was_heading(guarded):
|
||||
"""Po zalogowaniu ma wrócić tam, gdzie szedł — nie na stronę główną."""
|
||||
assert "dokad=/significators" in guarded.get("/significators").headers["location"]
|
||||
|
||||
|
||||
def test_rejects_wrong_user(guarded):
|
||||
assert guarded.get("/significators", headers=_basic("obcy", PASSWORD)).status_code == 401
|
||||
def test_no_session_no_content(guarded):
|
||||
"""Popsute albo podrobione ciasteczko jest tym samym, co jego brak."""
|
||||
from app import session
|
||||
|
||||
# Same znaki ASCII — nagłówek Cookie nie przenosi innych.
|
||||
for zle in ("", "smiec", "a.b", "...", session.issue(USER, "zly-odcisk")):
|
||||
r = guarded.get("/significators", headers={"Cookie": f"{session.COOKIE}={zle}"})
|
||||
assert r.status_code == 303, f"wpuszczono z ciasteczkiem: {zle!r}"
|
||||
|
||||
|
||||
def test_rejects_malformed_header(guarded):
|
||||
for bad in ("Basic !!!niebase64!!!", "Bearer cokolwiek", "", "Basic"):
|
||||
assert guarded.get("/significators", headers={"Authorization": bad}).status_code == 401
|
||||
|
||||
|
||||
def test_allows_correct_credentials(guarded):
|
||||
r = guarded.get("/significators", headers=_basic(USER, PASSWORD))
|
||||
def test_allows_a_valid_session(guarded):
|
||||
r = guarded.get("/significators", headers=_sesja())
|
||||
assert r.status_code == 200
|
||||
assert "treść z bazy" in r.text
|
||||
|
||||
|
||||
def test_credentials_are_checked_by_verify(monkeypatch):
|
||||
"""Samo sprawdzanie hasła — bez HTTP, bo to czysta funkcja."""
|
||||
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
|
||||
monkeypatch.delenv("APP_USERS", raising=False)
|
||||
assert security.verify(USER, PASSWORD) is not None
|
||||
assert security.verify(USER, "zle") is None
|
||||
assert security.verify("obcy", PASSWORD) is None
|
||||
|
||||
|
||||
def test_a_session_dies_when_the_password_changes(monkeypatch):
|
||||
"""Odebranie dostępu przez zmianę hasła MUSI ubić otwarte sesje — inaczej
|
||||
odebranie dostępu nie odbiera dostępu aż do wygaśnięcia sesji."""
|
||||
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
|
||||
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
|
||||
monkeypatch.setenv("COOKIES_INSECURE", "1")
|
||||
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
|
||||
client = TestClient(_app(), follow_redirects=False)
|
||||
ciasteczko = _sesja()
|
||||
assert client.get("/significators", headers=ciasteczko).status_code == 200
|
||||
|
||||
monkeypatch.setenv("APP_PASSWORD", "zupełnie-nowe-hasło")
|
||||
assert client.get("/significators", headers=ciasteczko).status_code == 303
|
||||
|
||||
|
||||
def test_health_stays_public(guarded):
|
||||
assert guarded.get("/health").status_code == 200
|
||||
|
||||
|
||||
def test_no_key_no_service(monkeypatch):
|
||||
"""Fail-closed: usługa z kontami, ale bez klucza podpisu, nie umiałaby
|
||||
odróżnić ważnej sesji od podrobionej — więc nie wstaje."""
|
||||
from app import session
|
||||
|
||||
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
|
||||
monkeypatch.delenv("SESSION_SECRET", raising=False)
|
||||
with pytest.raises(session.SessionError):
|
||||
_app()
|
||||
|
||||
|
||||
def test_open_when_password_unset(monkeypatch):
|
||||
"""Brak hasła = zgodność wstecz; ochrona wyłączona (i ostrzegamy przy starcie)."""
|
||||
"""Brak hasła = zgodność wstecz; ochrona wyłączona (i ostrzegamy przy starcie).
|
||||
Klucz sesji nie jest wtedy potrzebny — nie ma czego podpisywać."""
|
||||
monkeypatch.delenv("APP_PASSWORD", raising=False)
|
||||
monkeypatch.delenv("SESSION_SECRET", raising=False)
|
||||
assert not security.auth_enabled()
|
||||
assert TestClient(_app()).get("/significators").status_code == 200
|
||||
|
||||
@@ -99,8 +148,9 @@ def test_rate_limit_blocks_flood(monkeypatch):
|
||||
"""Masowe odpytywanie to droga eksfiltracji nawet po zalogowaniu."""
|
||||
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
|
||||
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "5")
|
||||
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
|
||||
client = TestClient(_app())
|
||||
auth = _basic(USER, PASSWORD)
|
||||
auth = _sesja()
|
||||
codes = [client.get("/significators", headers=auth).status_code for _ in range(8)]
|
||||
assert codes[:5] == [200] * 5
|
||||
assert 429 in codes[5:]
|
||||
@@ -109,8 +159,9 @@ def test_rate_limit_blocks_flood(monkeypatch):
|
||||
def test_rate_limited_response_has_retry_after(monkeypatch):
|
||||
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
|
||||
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "1")
|
||||
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
|
||||
client = TestClient(_app())
|
||||
auth = _basic(USER, PASSWORD)
|
||||
auth = _sesja()
|
||||
client.get("/significators", headers=auth)
|
||||
r = client.get("/significators", headers=auth)
|
||||
assert r.status_code == 429 and r.headers.get("Retry-After") == "60"
|
||||
@@ -121,7 +172,8 @@ def test_rate_limit_precedes_auth(monkeypatch):
|
||||
i sondowanie API jest darmowe."""
|
||||
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
|
||||
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "3")
|
||||
client = TestClient(_app())
|
||||
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
|
||||
client = TestClient(_app(), follow_redirects=False)
|
||||
codes = [client.get("/significators").status_code for _ in range(6)]
|
||||
assert 429 in codes
|
||||
|
||||
|
||||
Reference in New Issue
Block a user