feat(bezpieczeństwo): przycisk wylogowania (LOG-32)
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m17s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m29s
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 1m18s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m21s
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 5s
Testy / Kontrola składni wszystkich warstw (push) Successful in 4s

Aplikacja nie miała jak z siebie wyjść: raz podane hasło działało do zamknięcia
przeglądarki, a na wspólnym komputerze nie było sposobu, żeby oddać ekran komuś
innemu.

HTTP BASIC NIE MA PRAWDZIWEGO WYLOGOWANIA i to jest sedno tej zmiany. Nie ma
sesji do skasowania: przeglądarka zapamiętuje login i hasło, po czym dosyła je
SAMA przy każdym żądaniu. Serwer nie ma czego zapomnieć — kolejne kliknięcie
przyszłoby z kompletem poświadczeń i weszłoby z powrotem.

Działa natomiast doprowadzenie do tego, żeby to PRZEGLĄDARKA porzuciła zapamiętane
dane, i robimy to dwutorowo, bo ani jedno, ani drugie osobno nie wystarcza:

  1. /wyloguj odpowiada ZAWSZE 401 z nagłówkiem WWW-Authenticate, co wymusza
     ponowne pytanie o hasło. Działa bez JavaScriptu, ale samo w sobie zostawia
     stare dane w pamięci przeglądarki: po anulowaniu okienka wystarczyłoby wejść
     na dowolny adres, żeby wrócić do środka.
  2. wyloguj.js wysyła żądanie z CELOWO błędnymi danymi, którym przeglądarka
     nadpisuje swój wpis. To jest część, która faktycznie czyści pamięć — ale
     opiera się na zachowaniu powszechnym, a nie zapisanym w standardzie, więc
     nie może być jedynym mechanizmem.

Strona wylogowania mówi wprost, że pewnym sposobem w KAŻDEJ przeglądarce jest
zamknięcie okna. Nie obiecujemy więcej, niż Basic potrafi — obietnica bez pokrycia
byłaby tu gorsza od braku przycisku, bo dawałaby złudzenie, że ekran jest oddany.

Trasa stoi POZA bramką logowania, celowo: inaczej dostałaby 200 od zalogowanej
sesji i nie miałaby jak odpowiedzieć 401. Odpowiada też bez logowania — inaczej
wyjście wymagałoby bycia w środku, co jest błędnym kołem. Nagłówki zakazują
zapamiętania strony, bo oddana z pamięci podręcznej nie dotarłaby do serwera
i okienko w ogóle by się nie pojawiło.

Obok wyjścia pokazujemy, KTO jest zalogowany: bez tego przycisk jest w połowie
bezużyteczny, bo na wspólnym komputerze nie wiadomo, kogo się wylogowuje. Przy
wyłączonym logowaniu nie ma ani jednego, ani drugiego — przycisk sugerowałby
ochronę, której nie ma.

Sprawdzone w przeglądarce: oba żądania wychodzą (to z błędnymi danymi i samo
przejście), oba wracają 401, strona renderuje się poprawnie. Samego unieważnienia
pamięci poświadczeń NIE dało się tu potwierdzić — wymaga okienka systemowego,
którego automat nie obsłuży.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit was merged in pull request #75.
This commit is contained in:
2026-08-20 23:52:04 +02:00
parent ee3c515d59
commit 71bb3b9c0a
7 changed files with 202 additions and 1 deletions
@@ -409,3 +409,85 @@ def test_a_corrupt_file_is_never_silently_overwritten(env, tmp_path, monkeypatch
follow_redirects=False, data={"login": "ala", "password": "x"})
assert path.read_text(encoding="utf-8") == "{to nie jest json", \
"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ą.
def test_logout_always_answers_401_even_with_valid_credentials(env, monkeypatch):
"""Sedno: 200 dla zalogowanej osoby oznaczałoby, że nie da się wyjść."""
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"
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."""
c = _client(monkeypatch)
assert c.get("/wyloguj").status_code == 401
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."""
c = _client(monkeypatch)
cc = c.get("/wyloguj").headers.get("Cache-Control", "")
assert "no-store" in cc and "no-cache" in cc
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", [])
c = _client(monkeypatch)
assert c.get("/wyloguj", headers=_auth("bez", "x")).status_code == 401
def test_the_logout_button_is_on_every_page_with_the_login_next_to_it(env, monkeypatch):
"""Bez pokazania, KTO jest zalogowany, przycisk wyjścia jest w połowie
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"
assert kto in html, f"{kto}: nie widać, kto jest zalogowany"
def test_no_logout_button_when_authentication_is_off(env, monkeypatch):
"""Bez logowania nie ma z czego wychodzić, a przycisk sugerowałby ochronę,
której nie ma."""
monkeypatch.delenv("APP_PASSWORD", raising=False)
monkeypatch.delenv("APP_USERS", raising=False)
c = _client(monkeypatch)
assert 'href="/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
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ć"