Files
astrololo/services/presentation/tests/test_sesje.py
T
gitea 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
feat(bezpieczeństwo): sesje logowania zamiast HTTP Basic (LOG-34)
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>
2026-08-21 00:18:15 +02:00

144 lines
6.1 KiB
Python

"""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