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:
@@ -0,0 +1,143 @@
|
||||
"""Sesje logowania — rdzeń podpisywania (LOG-34).
|
||||
|
||||
Testy dzielą się na dwie grupy i ta druga jest ważniejsza: „czy działa" oraz
|
||||
„czy da się oszukać". Kod, który tylko przechodzi tę pierwszą, wpuszcza każdego,
|
||||
kto spróbuje czegoś innego niż zamierzony przebieg.
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from app import session
|
||||
|
||||
KLUCZ = "x" * 64
|
||||
ODCISK = session.fingerprint("scrypt$sól$hash")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def klucz(monkeypatch):
|
||||
monkeypatch.setenv("SESSION_SECRET", KLUCZ)
|
||||
monkeypatch.delenv("SESSION_MAX_AGE", raising=False)
|
||||
monkeypatch.delenv("SESSION_IDLE", raising=False)
|
||||
|
||||
|
||||
# ── klucz: fail-closed ───────────────────────────────────────────────────
|
||||
|
||||
def test_no_key_means_no_sessions_not_a_random_one(monkeypatch):
|
||||
"""Losowy klucz przy starcie wylogowywałby wszystkich przy każdym restarcie
|
||||
poda — wygląda jak awaria i uczy ludzi ignorować ekran logowania."""
|
||||
monkeypatch.delenv("SESSION_SECRET", raising=False)
|
||||
with pytest.raises(session.SessionError):
|
||||
session.secret()
|
||||
assert session.available() is False
|
||||
|
||||
|
||||
def test_a_short_key_is_refused_with_a_reason(monkeypatch):
|
||||
monkeypatch.setenv("SESSION_SECRET", "krótki")
|
||||
with pytest.raises(session.SessionError) as e:
|
||||
session.secret()
|
||||
assert "32" in str(e.value)
|
||||
|
||||
|
||||
# ── zwykły przebieg ──────────────────────────────────────────────────────
|
||||
|
||||
def test_issue_and_read_round_trip(klucz):
|
||||
dane = session.read(session.issue("ala", ODCISK, 3))
|
||||
assert dane["login"] == "ala" and dane["odcisk"] == ODCISK and dane["wersja"] == 3
|
||||
|
||||
|
||||
def test_fingerprint_never_carries_the_password(klucz):
|
||||
"""Odcisk jedzie do przeglądarki, więc nie może dać się cofnąć do hasła."""
|
||||
odcisk = session.fingerprint("tajne-hasło-użytkownika")
|
||||
assert "tajne" not in odcisk and "hasło" not in odcisk
|
||||
assert len(odcisk) == 16
|
||||
assert session.fingerprint("a") != session.fingerprint("b")
|
||||
|
||||
|
||||
# ── próby oszukania ──────────────────────────────────────────────────────
|
||||
|
||||
def test_a_tampered_payload_is_refused(klucz):
|
||||
token = session.issue("ala", ODCISK)
|
||||
surowy, _, podpis = token.rpartition(".")
|
||||
dane = json.loads(session._unb64(surowy))
|
||||
dane["u"] = "szef" # podmiana konta
|
||||
podrobiony = session._b64(json.dumps(dane, separators=(",", ":"),
|
||||
sort_keys=True).encode()) + "." + podpis
|
||||
assert session.read(podrobiony) is None
|
||||
|
||||
|
||||
def test_a_tampered_signature_is_refused(klucz):
|
||||
token = session.issue("ala", ODCISK)
|
||||
surowy, _, podpis = token.rpartition(".")
|
||||
assert session.read(f"{surowy}.{podpis[:-2]}xx") is None
|
||||
|
||||
|
||||
def test_a_token_signed_with_another_key_is_refused(klucz, monkeypatch):
|
||||
token = session.issue("ala", ODCISK)
|
||||
monkeypatch.setenv("SESSION_SECRET", "y" * 64)
|
||||
assert session.read(token) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("smiec", ["", None, "bez-kropki", ".", "a.b",
|
||||
"!!!.???", "a" * 500])
|
||||
def test_garbage_never_raises_it_just_refuses(klucz, smiec):
|
||||
"""Wejście przychodzi z przeglądarki, więc wyjątek tutaj to 500 na każdym
|
||||
żądaniu z popsutym ciasteczkiem — czyli aplikacja nie do odratowania
|
||||
inaczej niż przez wyczyszczenie przeglądarki."""
|
||||
assert session.read(smiec) is None
|
||||
|
||||
|
||||
def test_a_token_from_the_future_is_refused(klucz):
|
||||
"""Przestawiony zegar albo podrobienie — w obu razach nie ma powodu ufać."""
|
||||
przyszly = session.issue("ala", ODCISK, now=time.time() + 3600)
|
||||
assert session.read(przyszly) is None
|
||||
|
||||
|
||||
def test_reading_without_a_key_refuses_instead_of_exploding(klucz, monkeypatch):
|
||||
token = session.issue("ala", ODCISK)
|
||||
monkeypatch.delenv("SESSION_SECRET", raising=False)
|
||||
assert session.read(token) is None
|
||||
|
||||
|
||||
# ── wygasanie ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_a_session_dies_of_old_age(klucz, monkeypatch):
|
||||
monkeypatch.setenv("SESSION_MAX_AGE", "100")
|
||||
token = session.issue("ala", ODCISK, now=time.time() - 200)
|
||||
assert session.read(token) is None
|
||||
|
||||
|
||||
def test_a_session_dies_of_inactivity(klucz, monkeypatch):
|
||||
"""Twardy kres jeszcze daleko, ale nikt nie ruszał — koniec."""
|
||||
monkeypatch.setenv("SESSION_MAX_AGE", "100000")
|
||||
monkeypatch.setenv("SESSION_IDLE", "60")
|
||||
stary = session.issue("ala", ODCISK, now=time.time() - 600)
|
||||
assert session.read(stary) is None
|
||||
|
||||
|
||||
def test_activity_extends_the_session_but_not_past_the_hard_limit(klucz, monkeypatch):
|
||||
monkeypatch.setenv("SESSION_MAX_AGE", "1000")
|
||||
monkeypatch.setenv("SESSION_IDLE", "100")
|
||||
teraz = time.time()
|
||||
token = session.issue("ala", ODCISK, now=teraz - 900)
|
||||
odswiezony = session.refresh(session.read(token, now=teraz - 900), now=teraz - 10)
|
||||
assert session.read(odswiezony, now=teraz) is not None, "ruch nie przedłużył sesji"
|
||||
# ...ale twardego kresu nie przeskoczy
|
||||
assert session.read(odswiezony, now=teraz + 200) is None
|
||||
|
||||
|
||||
def test_refresh_stays_quiet_until_it_is_worth_it(klucz):
|
||||
"""Bez progu każde żądanie odsyłałoby Set-Cookie — także po arkusze stylów."""
|
||||
dane = session.read(session.issue("ala", ODCISK))
|
||||
assert session.refresh(dane) is None
|
||||
assert session.refresh(dane, now=time.time() + session.REFRESH_AFTER + 1) is not None
|
||||
|
||||
|
||||
# ── ustawienia ciasteczka ────────────────────────────────────────────────
|
||||
|
||||
def test_the_cookie_cannot_be_read_by_scripts_or_sent_cross_site(klucz):
|
||||
p = session.cookie_params()
|
||||
assert p["httponly"] is True, "jeden wstrzyknięty skrypt wyniósłby sesję"
|
||||
assert p["samesite"] == "strict", "obca strona mogłaby działać w imieniu zalogowanego"
|
||||
assert p["secure"] is True
|
||||
Reference in New Issue
Block a user