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:
@@ -5,7 +5,6 @@ krytyczne i CICHE, gdy się zepsują: (1) odebranie dostępu jednej osobie musi
|
||||
faktycznie działać, (2) dziennik musi mówić KTO i ILE, ale NIGDY nie powielać
|
||||
treści baz (byłby wtedy kolejnym nośnikiem wycieku).
|
||||
"""
|
||||
import base64
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
@@ -13,9 +12,13 @@ import pytest
|
||||
from app import security
|
||||
|
||||
|
||||
def _basic(user: str, password: str) -> dict:
|
||||
raw = base64.b64encode(f"{user}:{password}".encode()).decode()
|
||||
return {"Authorization": f"Basic {raw}"}
|
||||
def _kto(user: str, password: str) -> str | None:
|
||||
"""Login, gdy poświadczenia pasują; None przy odmowie.
|
||||
|
||||
Po przejściu na sesje (LOG-34) sprawdzanie hasła robi `verify`, a nie
|
||||
parsowanie nagłówka Basic — samego nagłówka nie ma już w ogóle."""
|
||||
who = security.verify(user, password)
|
||||
return who.login if who else None
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -57,29 +60,29 @@ def test_named_accounts_are_parsed(monkeypatch):
|
||||
|
||||
def test_login_with_hashed_password(monkeypatch):
|
||||
monkeypatch.setenv("APP_USERS", f"alicja:{security.hash_password('tajne')}")
|
||||
assert security.authenticate(_basic("alicja", "tajne")["Authorization"]) == "alicja"
|
||||
assert security.authenticate(_basic("alicja", "złe")["Authorization"]) is None
|
||||
assert _kto("alicja", "tajne") == "alicja"
|
||||
assert _kto("alicja", "złe") is None
|
||||
|
||||
|
||||
def test_unknown_user_is_denied(monkeypatch):
|
||||
monkeypatch.setenv("APP_USERS", "alicja:tajne")
|
||||
assert security.authenticate(_basic("obcy", "tajne")["Authorization"]) is None
|
||||
assert _kto("obcy", "tajne") is None
|
||||
|
||||
|
||||
def test_revoking_one_account_leaves_others_untouched(monkeypatch):
|
||||
"""Sedno PRE-17: odcinamy jedną osobę BEZ zmiany hasła pozostałym."""
|
||||
monkeypatch.setenv("APP_USERS", "alicja:tajne,bartek:inne")
|
||||
assert security.authenticate(_basic("bartek", "inne")["Authorization"]) == "bartek"
|
||||
assert _kto("bartek", "inne") == "bartek"
|
||||
monkeypatch.setenv("APP_USERS", "alicja:tajne") # usunięto wpis Bartka
|
||||
assert security.authenticate(_basic("bartek", "inne")["Authorization"]) is None
|
||||
assert security.authenticate(_basic("alicja", "tajne")["Authorization"]) == "alicja"
|
||||
assert _kto("bartek", "inne") is None
|
||||
assert _kto("alicja", "tajne") == "alicja"
|
||||
|
||||
|
||||
def test_shared_password_still_works_alone(monkeypatch):
|
||||
"""Zgodność wstecz — dotychczasowe wdrożenie nie może paść po aktualizacji."""
|
||||
monkeypatch.setenv("APP_USER", "astrololo")
|
||||
monkeypatch.setenv("APP_PASSWORD", "wspolne")
|
||||
assert security.authenticate(_basic("astrololo", "wspolne")["Authorization"]) == "astrololo"
|
||||
assert _kto("astrololo", "wspolne") == "astrololo"
|
||||
|
||||
|
||||
def test_named_accounts_disable_the_shared_password(monkeypatch):
|
||||
@@ -88,7 +91,7 @@ def test_named_accounts_disable_the_shared_password(monkeypatch):
|
||||
monkeypatch.setenv("APP_USERS", "alicja:tajne")
|
||||
monkeypatch.setenv("APP_USER", "astrololo")
|
||||
monkeypatch.setenv("APP_PASSWORD", "wspolne")
|
||||
assert security.authenticate(_basic("astrololo", "wspolne")["Authorization"]) is None
|
||||
assert _kto("astrololo", "wspolne") is None
|
||||
|
||||
|
||||
def test_no_accounts_means_auth_disabled(monkeypatch):
|
||||
@@ -97,28 +100,82 @@ def test_no_accounts_means_auth_disabled(monkeypatch):
|
||||
|
||||
# ── dziennik audytowy ──
|
||||
|
||||
def _client():
|
||||
def _client(monkeypatch):
|
||||
# Klucz MUSI być w środowisku ZANIM zaimportujemy app.main: security.install
|
||||
# biegnie przy imporcie i bez klucza celowo nie pozwala usłudze wstać.
|
||||
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
|
||||
monkeypatch.setenv("COOKIES_INSECURE", "1")
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
return TestClient(app)
|
||||
return TestClient(app, follow_redirects=False)
|
||||
|
||||
|
||||
class _dziennik:
|
||||
"""Przechwytuje wpisy audytowe.
|
||||
|
||||
`caplog` podpina się pod korzeń, a dziennik audytowy ma celowo
|
||||
`propagate=False` — żeby wpisy nie dublowały się w logach usługi. Trzeba więc
|
||||
podpiąć uchwyt WPROST pod niego, inaczej test widzi pustkę i wygląda to na
|
||||
brak wpisów zamiast na brak podsłuchu."""
|
||||
|
||||
def __init__(self, caplog):
|
||||
self.caplog = caplog
|
||||
|
||||
def __enter__(self):
|
||||
security._setup_audit_logging()
|
||||
security.audit_log.setLevel(logging.INFO)
|
||||
security.audit_log.addHandler(self.caplog.handler)
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
security.audit_log.removeHandler(self.caplog.handler)
|
||||
|
||||
@property
|
||||
def tekst(self) -> str:
|
||||
return "\n".join(r.getMessage() for r in self.caplog.records)
|
||||
|
||||
|
||||
def _sesja(user: str) -> dict:
|
||||
from app import session
|
||||
|
||||
return {"Cookie": f"{session.COOKIE}={security.issue_session(user)}"}
|
||||
|
||||
|
||||
def test_audit_logs_who_and_how_many(monkeypatch, caplog):
|
||||
monkeypatch.setenv("APP_USERS", "alicja:tajne")
|
||||
with caplog.at_level(logging.INFO, logger="astrololo.audit"):
|
||||
_client().get("/synastry", headers=_basic("alicja", "tajne"))
|
||||
entry = "\n".join(r.getMessage() for r in caplog.records)
|
||||
c = _client(monkeypatch)
|
||||
with _dziennik(caplog) as d:
|
||||
c.get("/synastry", headers=_sesja("alicja"))
|
||||
entry = d.tekst
|
||||
assert "user=alicja" in entry and "path=/synastry" in entry and "status=200" in entry
|
||||
|
||||
|
||||
def test_audit_marks_failed_login_without_naming_the_account(monkeypatch, caplog):
|
||||
def test_audit_marks_a_request_without_a_session(monkeypatch, caplog):
|
||||
"""Odesłanie na ekran logowania też zostawia ślad — bez tego seria prób
|
||||
dobicia się do chronionej ścieżki byłaby w dzienniku niewidoczna."""
|
||||
monkeypatch.setenv("APP_USERS", "alicja:tajne")
|
||||
with caplog.at_level(logging.INFO, logger="astrololo.audit"):
|
||||
_client().get("/synastry", headers=_basic("alicja", "złe"))
|
||||
entry = "\n".join(r.getMessage() for r in caplog.records)
|
||||
assert "status=401" in entry and "user=-" in entry
|
||||
c = _client(monkeypatch)
|
||||
with _dziennik(caplog) as d:
|
||||
c.get("/synastry")
|
||||
entry = d.tekst
|
||||
assert "status=303" in entry and "user=-" in entry
|
||||
|
||||
|
||||
def test_failed_login_is_logged_without_saying_which_account_exists(monkeypatch, caplog):
|
||||
"""Nieudane próby są w dzienniku ważniejsze od udanych: pojedyncza nic nie
|
||||
znaczy, ale seria pod jednym adresem to jedyny widoczny ślad zgadywania
|
||||
haseł. Hasło ani jego fragment NIE mogą tam trafić."""
|
||||
monkeypatch.setenv("APP_USERS", f"alicja:{security.hash_password('tajne')}")
|
||||
c = _client(monkeypatch)
|
||||
with _dziennik(caplog) as d:
|
||||
r = c.post("/logowanie", data={"login": "alicja", "haslo": "zgaduje-haslo"})
|
||||
assert r.status_code == 401
|
||||
entry = d.tekst
|
||||
assert "logowanie-odmowa" in entry
|
||||
assert "zgaduje-haslo" not in entry, "hasło trafiło do dziennika"
|
||||
|
||||
|
||||
def test_audit_logger_actually_emits(monkeypatch):
|
||||
|
||||
@@ -28,7 +28,10 @@ def env(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("APP_USER", "szef")
|
||||
monkeypatch.setenv("APP_PASSWORD", "tajne-szefa")
|
||||
monkeypatch.delenv("APP_USERS", raising=False)
|
||||
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0") # limit nie jest tu przedmiotem badania
|
||||
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
|
||||
# Bez klucza podpisu usługa celowo nie wstaje (fail-closed, LOG-34).
|
||||
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
|
||||
monkeypatch.setenv("COOKIES_INSECURE", "1") # TestClient jedzie po http # limit nie jest tu przedmiotem badania
|
||||
return tmp_path
|
||||
|
||||
|
||||
@@ -41,11 +44,16 @@ def _client(monkeypatch, chart=None):
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _auth(user, password):
|
||||
import base64
|
||||
def _auth(user, password=""):
|
||||
"""Nagłówek z WAŻNĄ SESJĄ dla konta — odpowiednik bycia zalogowanym.
|
||||
|
||||
raw = base64.b64encode(f"{user}:{password}".encode()).decode()
|
||||
return {"Authorization": f"Basic {raw}"}
|
||||
Po przejściu z Basic na sesje (LOG-34) „zalogowany" nie znaczy już „ma
|
||||
nagłówek z hasłem", tylko „ma podpisane ciasteczko". Hasło jest tu nieistotne
|
||||
i przyjmowane wyłącznie po to, żeby nie przepisywać wszystkich wywołań —
|
||||
sprawdzanie poświadczeń ma własne testy, które idą przez /logowanie."""
|
||||
from app import security, session
|
||||
|
||||
return {"Cookie": f"{session.COOKIE}={security.issue_session(user)}"}
|
||||
|
||||
|
||||
def _chart():
|
||||
@@ -130,7 +138,7 @@ def test_empty_password_on_update_keeps_the_old_one(env):
|
||||
# ── logowanie i rozpoznanie konta ────────────────────────────────────────
|
||||
|
||||
def test_environment_account_is_the_administrator(env):
|
||||
who = security.principal(_auth("szef", "tajne-szefa")["Authorization"])
|
||||
who = security.verify("szef", "tajne-szefa")
|
||||
assert who and who.is_admin
|
||||
assert features.ADMIN in who.permissions
|
||||
assert features.GRANTABLE <= who.permissions, "administrator ma widzieć wszystko"
|
||||
@@ -138,7 +146,7 @@ def test_environment_account_is_the_administrator(env):
|
||||
|
||||
def test_managed_account_gets_exactly_its_own_permissions(env):
|
||||
store.create("ala", "hasło-ali", ["chart", "ai"])
|
||||
who = security.principal(_auth("ala", "hasło-ali")["Authorization"])
|
||||
who = security.verify("ala", "hasło-ali")
|
||||
assert who and not who.is_admin
|
||||
assert who.permissions == frozenset({"chart", "ai"})
|
||||
|
||||
@@ -147,14 +155,14 @@ def test_managed_account_cannot_shadow_the_administrator(env):
|
||||
"""Konto założone w pliku o loginie administratora NIE MOŻE go przesłonić —
|
||||
inaczej dałoby się odebrać uprawnienia jedynemu, kto może je nadawać."""
|
||||
store.create("szef", "podszywam-się", [])
|
||||
who = security.principal(_auth("szef", "tajne-szefa")["Authorization"])
|
||||
who = security.verify("szef", "tajne-szefa")
|
||||
assert who and who.is_admin and features.ADMIN in who.permissions
|
||||
|
||||
|
||||
def test_wrong_password_is_refused_the_same_way_as_unknown_login(env):
|
||||
store.create("ala", "hasło-ali", ["chart"])
|
||||
assert security.principal(_auth("ala", "złe")["Authorization"]) is None
|
||||
assert security.principal(_auth("nie-ma-takiego", "cokolwiek")["Authorization"]) is None
|
||||
assert security.verify("ala", "złe") is None
|
||||
assert security.verify("nie-ma-takiego", "cokolwiek") is None
|
||||
|
||||
|
||||
# ── niewidzialność funkcji ───────────────────────────────────────────────
|
||||
@@ -294,9 +302,10 @@ def test_administrator_creates_an_account_and_it_works_immediately(env, monkeypa
|
||||
data={"login": "nowa", "granted": ["chart"]})
|
||||
assert c.get("/significators", headers=her).status_code == 404
|
||||
|
||||
# skasowanie konta odcina logowanie
|
||||
# skasowanie konta ubija też OTWARTĄ sesję — bez tego odebranie dostępu
|
||||
# działałoby dopiero po jej wygaśnięciu
|
||||
c.post("/accounts/delete", headers=admin, follow_redirects=False, data={"login": "nowa"})
|
||||
assert c.get("/", headers=her).status_code == 401
|
||||
assert c.get("/", headers=her, follow_redirects=False).status_code == 303
|
||||
|
||||
|
||||
def test_deleting_an_account_cannot_touch_the_administrator(env, monkeypatch):
|
||||
@@ -411,50 +420,59 @@ def test_a_corrupt_file_is_never_silently_overwritten(env, tmp_path, monkeypatch
|
||||
"uszkodzony plik został nadpisany — konta by zniknęły"
|
||||
|
||||
|
||||
# ── wylogowanie (LOG-32) ─────────────────────────────────────────────────
|
||||
# HTTP Basic nie ma sesji: przeglądarka dosyła zapamiętane dane przy KAŻDYM
|
||||
# żądaniu, więc serwer nie ma czego zapomnieć. Jedyne, co działa, to doprowadzić
|
||||
# do porzucenia tych danych przez przeglądarkę — stąd 401 z tej trasy jest
|
||||
# WYNIKIEM, nie awarią.
|
||||
# ── logowanie i wylogowanie (LOG-34) ─────────────────────────────────────
|
||||
# Sesja zastąpiła HTTP Basic. 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ąć.
|
||||
|
||||
def test_logout_always_answers_401_even_with_valid_credentials(env, monkeypatch):
|
||||
"""Sedno: 200 dla zalogowanej osoby oznaczałoby, że nie da się wyjść."""
|
||||
def test_logging_in_hands_out_a_session_and_takes_you_where_you_were_going(env, monkeypatch):
|
||||
store.create("ala", "hasło-ali", ["chart"])
|
||||
c = _client(monkeypatch)
|
||||
r = c.get("/wyloguj", headers=_auth("szef", "tajne-szefa"))
|
||||
assert r.status_code == 401
|
||||
assert r.headers.get("WWW-Authenticate", "").lower().startswith("basic"), \
|
||||
"bez tego nagłówka przeglądarka nie zapyta ponownie o hasło"
|
||||
r = c.post("/logowanie", follow_redirects=False,
|
||||
data={"login": "ala", "haslo": "hasło-ali", "dokad": "/"})
|
||||
assert r.status_code == 303 and r.headers["location"] == "/"
|
||||
from app import session
|
||||
|
||||
assert session.COOKIE in r.cookies, "nie wydano ciasteczka sesji"
|
||||
|
||||
|
||||
def test_logout_works_without_being_logged_in(env, monkeypatch):
|
||||
"""Trasa stoi poza bramką, więc wyjście działa też z nieważnymi danymi —
|
||||
inaczej wylogowanie wymagałoby bycia zalogowanym, co jest błędnym kołem."""
|
||||
def test_a_bad_password_says_the_same_thing_as_an_unknown_login(env, monkeypatch):
|
||||
"""Rozróżnienie mówiłoby, które konta istnieją."""
|
||||
store.create("ala", "hasło-ali", ["chart"])
|
||||
c = _client(monkeypatch)
|
||||
assert c.get("/wyloguj").status_code == 401
|
||||
zle = c.post("/logowanie", data={"login": "ala", "haslo": "złe"})
|
||||
obcy = c.post("/logowanie", data={"login": "nie-ma-takiego", "haslo": "cokolwiek"})
|
||||
assert zle.status_code == obcy.status_code == 401
|
||||
# Odpowiedzi różnią się WYŁĄCZNIE odbitym polem loginu — czyli tym, co sam
|
||||
# wpisał pytający. Wszystko poza tym musi być identyczne, inaczej dałoby się
|
||||
# wyliczyć, które konta istnieją.
|
||||
bez_loginu = lambda t, login: t.replace(f'value="{login}"', 'value="X"')
|
||||
assert "Nieprawidłowy login lub hasło" in zle.text
|
||||
assert bez_loginu(zle.text, "ala") == bez_loginu(obcy.text, "nie-ma-takiego")
|
||||
|
||||
|
||||
def test_logout_page_is_never_cached(env, monkeypatch):
|
||||
"""Oddana z pamięci podręcznej nie dotarłaby do serwera, więc okienko
|
||||
logowania w ogóle by się nie pojawiło."""
|
||||
def test_logging_out_drops_the_session(env, monkeypatch):
|
||||
"""Sedno całej zmiany: po wylogowaniu przeglądarka nie ma już czego dosyłać."""
|
||||
store.create("ala", "x", ["chart"])
|
||||
c = _client(monkeypatch)
|
||||
cc = c.get("/wyloguj").headers.get("Cache-Control", "")
|
||||
assert "no-store" in cc and "no-cache" in cc
|
||||
assert c.get("/", headers=_auth("ala"), follow_redirects=False).status_code == 200
|
||||
|
||||
r = c.post("/wyloguj", headers=_auth("ala"), follow_redirects=False)
|
||||
assert r.status_code == 303 and "/logowanie" in r.headers["location"]
|
||||
# Ciasteczko skasowane: przeglądarka dostaje puste z natychmiastowym kresem.
|
||||
from app import session
|
||||
|
||||
ustawione = r.headers.get("set-cookie", "")
|
||||
assert session.COOKIE in ustawione
|
||||
assert "Max-Age=0" in ustawione or 'expires=Thu, 01 Jan 1970' in ustawione.lower()
|
||||
|
||||
|
||||
def test_logout_page_explains_what_actually_ends_the_session(env, monkeypatch):
|
||||
"""Nie wolno obiecywać więcej, niż Basic potrafi: pewne wyjście na wspólnym
|
||||
komputerze to zamknięcie przeglądarki i strona ma to mówić wprost."""
|
||||
html = _client(monkeypatch).get("/wyloguj").text
|
||||
assert "Wylogowano" in html
|
||||
assert "zamknij okno" in html.lower()
|
||||
assert 'href="/"' in html, "brak drogi powrotnej"
|
||||
|
||||
|
||||
def test_every_account_can_leave_regardless_of_permissions(env, monkeypatch):
|
||||
"""Konto bez ani jednego ekranu też ma prawo wyjść."""
|
||||
store.create("bez", "x", [])
|
||||
def test_logging_out_is_a_post_not_a_link(env, monkeypatch):
|
||||
"""Pod adresem GET wystarczyłby obrazek na obcej stronie, żeby wylogować
|
||||
kogoś bez jego wiedzy. Uciążliwość, nie włamanie — ale darmowa do uniknięcia."""
|
||||
c = _client(monkeypatch)
|
||||
assert c.get("/wyloguj", headers=_auth("bez", "x")).status_code == 401
|
||||
assert c.get("/wyloguj", headers=_auth("szef"),
|
||||
follow_redirects=False).status_code == 405
|
||||
|
||||
|
||||
def test_the_logout_button_is_on_every_page_with_the_login_next_to_it(env, monkeypatch):
|
||||
@@ -462,9 +480,9 @@ def test_the_logout_button_is_on_every_page_with_the_login_next_to_it(env, monke
|
||||
bezużyteczny — na wspólnym komputerze nie wiadomo, kogo się wylogowuje."""
|
||||
store.create("ala", "x", ["chart", "significators"])
|
||||
c = _client(monkeypatch)
|
||||
for kto, haslo in (("szef", "tajne-szefa"), ("ala", "x")):
|
||||
html = c.get("/", headers=_auth(kto, haslo)).text
|
||||
assert 'href="/wyloguj"' in html, f"{kto}: brak wyjścia"
|
||||
for kto in ("szef", "ala"):
|
||||
html = c.get("/", headers=_auth(kto)).text
|
||||
assert 'action="/wyloguj"' in html, f"{kto}: brak wyjścia"
|
||||
assert kto in html, f"{kto}: nie widać, kto jest zalogowany"
|
||||
|
||||
|
||||
@@ -474,20 +492,62 @@ def test_no_logout_button_when_authentication_is_off(env, monkeypatch):
|
||||
monkeypatch.delenv("APP_PASSWORD", raising=False)
|
||||
monkeypatch.delenv("APP_USERS", raising=False)
|
||||
c = _client(monkeypatch)
|
||||
assert 'href="/wyloguj"' not in c.get("/").text
|
||||
assert 'action="/wyloguj"' not in c.get("/").text
|
||||
|
||||
|
||||
def test_the_browser_side_step_is_wired_in(env, monkeypatch):
|
||||
"""Sam 401 zostawiłby stare dane w pamięci przeglądarki: po anulowaniu
|
||||
okienka wystarczyłoby wejść na dowolny adres, żeby wrócić do środka.
|
||||
Skrypt dokłada żądanie z celowo błędnymi danymi, które ten wpis nadpisuje."""
|
||||
import pathlib
|
||||
def test_every_account_can_leave_regardless_of_permissions(env, monkeypatch):
|
||||
"""Konto bez ani jednego ekranu też ma prawo wyjść."""
|
||||
store.create("bez", "x", [])
|
||||
c = _client(monkeypatch)
|
||||
assert c.post("/wyloguj", headers=_auth("bez"),
|
||||
follow_redirects=False).status_code == 303
|
||||
|
||||
html = _client(monkeypatch).get("/", headers=_auth("szef", "tajne-szefa")).text
|
||||
assert "wyloguj.js" in html
|
||||
assert "data-wyloguj" in html
|
||||
|
||||
js = (pathlib.Path(__file__).resolve().parents[1] / "app" / "static"
|
||||
/ "wyloguj.js").read_text(encoding="utf-8")
|
||||
assert "Authorization" in js and "btoa" in js
|
||||
assert "Date.now()" in js, "bez zmiennej części dane mogłyby się powtórzyć"
|
||||
def test_the_login_screen_cannot_be_turned_into_a_redirector(env, monkeypatch):
|
||||
"""`?dokad=https://obcy.pl` zamieniłby nasz ekran logowania w narzędzie do
|
||||
odsyłania ludzi gdzie indziej — droga do wyłudzenia hasła, bo odsyłacz
|
||||
prowadzi z zaufanego adresu."""
|
||||
c = _client(monkeypatch)
|
||||
for zly in ("https://obcy.pl", "//obcy.pl", "http://obcy.pl/x", "\\\\obcy.pl"):
|
||||
r = c.post("/logowanie", follow_redirects=False,
|
||||
data={"login": "szef", "haslo": "tajne-szefa", "dokad": zly})
|
||||
assert r.status_code == 303
|
||||
assert r.headers["location"] == "/", f"przepuszczono: {zly}"
|
||||
|
||||
|
||||
def test_changing_the_password_kills_open_sessions(env, 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."""
|
||||
store.create("ala", "stare", ["chart"])
|
||||
c = _client(monkeypatch)
|
||||
ciasteczko = _auth("ala")
|
||||
assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 200
|
||||
|
||||
store.update("ala", password="nowe")
|
||||
assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 303
|
||||
|
||||
|
||||
def test_the_administrator_can_close_all_sessions_of_an_account(env, monkeypatch):
|
||||
"""„Wyrzuć zewsząd, ale hasło zostaw" — na przykład gdy ktoś zostawił
|
||||
zalogowany komputer."""
|
||||
store.create("ala", "x", ["chart"])
|
||||
c = _client(monkeypatch)
|
||||
ciasteczko = _auth("ala")
|
||||
assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 200
|
||||
|
||||
c.post("/accounts/sesje", headers=_auth("szef"), follow_redirects=False,
|
||||
data={"login": "ala"})
|
||||
assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 303
|
||||
# ...a hasło dalej działa
|
||||
assert security.verify("ala", "x") is not None
|
||||
|
||||
|
||||
def test_the_administrator_gets_in_even_when_the_accounts_file_is_broken(env, monkeypatch):
|
||||
"""Jedyna sytuacja, w której ktoś MUSI wejść, żeby to naprawić. Trzymanie
|
||||
stanu administratora w tym samym pliku dawałoby zakleszczenie."""
|
||||
import pathlib as _p
|
||||
|
||||
_p.Path(store.store_path()).write_text("{to nie jest json", encoding="utf-8")
|
||||
c = _client(monkeypatch)
|
||||
assert c.get("/accounts", headers=_auth("szef")).status_code == 200
|
||||
assert security.verify("szef", "tajne-szefa") is not None
|
||||
|
||||
@@ -21,14 +21,22 @@ def env(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("APP_PASSWORD", "tajne-szefa")
|
||||
monkeypatch.delenv("APP_USERS", raising=False)
|
||||
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
|
||||
# Bez klucza podpisu usługa celowo nie wstaje (fail-closed, LOG-34).
|
||||
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
|
||||
monkeypatch.setenv("COOKIES_INSECURE", "1") # TestClient jedzie po http
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _auth(user, password):
|
||||
import base64
|
||||
def _auth(user, password=""):
|
||||
"""Nagłówek z WAŻNĄ SESJĄ dla konta — odpowiednik bycia zalogowanym.
|
||||
|
||||
return {"Authorization": "Basic " + base64.b64encode(
|
||||
f"{user}:{password}".encode()).decode()}
|
||||
Po przejściu z Basic na sesje (LOG-34) „zalogowany" nie znaczy już „ma
|
||||
nagłówek z hasłem", tylko „ma podpisane ciasteczko". Hasło jest tu nieistotne
|
||||
i przyjmowane wyłącznie po to, żeby nie przepisywać wszystkich wywołań —
|
||||
sprawdzanie poświadczeń ma własne testy, które idą przez /logowanie."""
|
||||
from app import security, session
|
||||
|
||||
return {"Cookie": f"{session.COOKIE}={security.issue_session(user)}"}
|
||||
|
||||
|
||||
REGISTRY = [
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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