From 71bb3b9c0a68258694c273e1927aca57633b86e8 Mon Sep 17 00:00:00 2001 From: migatu Date: Thu, 20 Aug 2026 23:52:04 +0200 Subject: [PATCH] =?UTF-8?q?feat(bezpiecze=C5=84stwo):=20przycisk=20wylogow?= =?UTF-8?q?ania=20(LOG-32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- services/presentation/app/features.py | 3 + services/presentation/app/main.py | 45 ++++++++++ services/presentation/app/security.py | 6 +- services/presentation/app/static/styles.css | 11 +++ services/presentation/app/static/wyloguj.js | 44 ++++++++++ services/presentation/app/templates/base.html | 12 +++ .../tests/test_kontrola_dostepu.py | 82 +++++++++++++++++++ 7 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 services/presentation/app/static/wyloguj.js diff --git a/services/presentation/app/features.py b/services/presentation/app/features.py index 103aa8f..7fc1700 100644 --- a/services/presentation/app/features.py +++ b/services/presentation/app/features.py @@ -109,6 +109,9 @@ ROUTES: dict[tuple[str, str], str | None] = { ("GET", "/reverse"): None, ("GET", "/timezone"): None, ("GET", "/health"): None, + # Wylogowanie musi być dostępne dla każdego zalogowanego, niezależnie od tego, + # co mu przyznano — konto bez ani jednego ekranu też ma prawo wyjść. + ("GET", "/wyloguj"): None, } diff --git a/services/presentation/app/main.py b/services/presentation/app/main.py index 1ec7e53..4ef4c07 100644 --- a/services/presentation/app/main.py +++ b/services/presentation/app/main.py @@ -86,6 +86,9 @@ def _perms(request: Request) -> frozenset[str]: # dokładnie ten rodzaj sprytu, który po roku zostawia kogoś z dostępem, o którym # nikt już nie pamięta. templates.env.globals["nav_screens"] = lambda request: features.screens_for(_perms(request)) +# Kto jest zalogowany i czy logowanie w ogóle działa — pod przycisk wyjścia. +templates.env.globals["current_user"] = lambda request: getattr(request.state, "user", "") or "" +templates.env.globals["auth_on"] = security.auth_enabled templates.env.globals["can"] = lambda request, key: key in _perms(request) @@ -879,6 +882,48 @@ def accounts_delete(request: Request, login: str = Form(...)): return _accounts_redirect(done=f"Skasowano konto „{login}”.") +# ---------------- Wylogowanie (LOG-32) ---------------- +# HTTP Basic NIE MA prawdziwego wylogowania i trzeba to powiedzieć wprost: +# przeglądarka zapamiętuje dane logowania i dosyła je sama przy każdym żądaniu, +# więc „zapomnienie" po stronie serwera nic nie daje — kolejne kliknięcie weszłoby +# z powrotem. Nie ma czego wyczyścić w sesji, bo sesji nie ma. +# +# Działa natomiast jedno: doprowadzić do tego, żeby PRZEGLĄDARKA porzuciła to, co +# zapamiętała. Robimy to dwutorowo: +# +# 1. ta trasa odpowiada ZAWSZE 401 z nagłówkiem WWW-Authenticate, co zmusza +# przeglądarkę do ponownego zapytania o hasło — działa też bez JavaScriptu, +# 2. wyloguj.js dokłada to, co faktycznie CZYŚCI pamięć przeglądarki: żądanie +# z celowo błędnymi danymi, którym nadpisuje ona zapamiętany wpis. +# +# Sam punkt 1 zostawiłby stare dane w pamięci przeglądarki: po anulowaniu okienka +# wystarczyłoby wejść na dowolny adres, żeby wrócić do środka. Dlatego oba naraz. + +@app.get("/wyloguj", response_class=HTMLResponse) +def wyloguj() -> HTMLResponse: + """Kończy sesję przeglądarki. Zawsze 401 — to jest tu WYNIK, nie awaria.""" + strona = ( + '' + 'astrololo · wylogowano' + f'' + '

astrololo

' + '

Wylogowano.

' + '

Aby wrócić, zaloguj się ponownie.

' + '

Jeśli komputer jest współdzielony, zamknij okno ' + 'przeglądarki — to jedyny sposób pewny w każdej przeglądarce.

' + '
' + ) + return HTMLResponse( + strona, status_code=401, + headers={"WWW-Authenticate": 'Basic realm="astrololo"', + # Bez tego przeglądarka mogłaby oddać tę stronę z pamięci + # podręcznej przy kolejnym wylogowaniu, nie pytając serwera — + # a wtedy okienko logowania w ogóle by się nie pojawiło. + "Cache-Control": "no-store, no-cache, must-revalidate", + "Pragma": "no-cache"}, + ) + + @app.get("/health") def health() -> dict: return {"status": "ok", "layer": "presentation"} diff --git a/services/presentation/app/security.py b/services/presentation/app/security.py index 3f58b80..4d9e166 100644 --- a/services/presentation/app/security.py +++ b/services/presentation/app/security.py @@ -118,7 +118,11 @@ def trust_proxy() -> bool: return os.getenv("TRUST_PROXY", "").strip().lower() in {"1", "true", "yes", "on"} -PUBLIC_PATHS = frozenset({"/health"}) +# /wyloguj jest „publiczna" tylko technicznie: przechodzi obok bramki, żeby móc +# odpowiedzieć 401 CELOWO, zamiast dostać 200 od zalogowanej sesji. Bez tego +# wylogowanie byłoby niemożliwe — przeglądarka dosyła zapamiętane dane przy każdym +# żądaniu, więc handler i tak widziałby zalogowaną osobę. +PUBLIC_PATHS = frozenset({"/health", "/wyloguj"}) PUBLIC_PREFIXES = ("/static/",) _hits: dict[str, deque[float]] = {} diff --git a/services/presentation/app/static/styles.css b/services/presentation/app/static/styles.css index 8c10737..ce121dc 100644 --- a/services/presentation/app/static/styles.css +++ b/services/presentation/app/static/styles.css @@ -262,3 +262,14 @@ tr.row-archived td { opacity: .55; } /* Wstrzymane widzi tylko administrator — dla reszty tych wierszy nie ma w ogóle w odpowiedzi, więc ten styl nigdy nie dotyczy ich strony. */ tr.row-held td { background: rgba(184, 134, 11, .07); } + + +/* ── wyjście z aplikacji (LOG-32) ───────────────────────────────────── */ +/* Odsunięte od zakładek i wyglądające inaczej niż one: to czynność, nie miejsce, + a pomylenie go z zakładką kosztuje przypadkowe wylogowania. Odstęp robimy + marginesem, nie rozpychaczem — `nav` ma szerokość treści, więc nie ma czego + rozpychać. */ +.whoami { color: var(--muted); font-size: .85rem; align-self: center; + margin-left: 1rem; padding-left: 1rem; border-left: 1px solid var(--line); } +nav a.wyloguj { color: var(--muted); } +nav a.wyloguj:hover { color: var(--fg, inherit); } diff --git a/services/presentation/app/static/wyloguj.js b/services/presentation/app/static/wyloguj.js new file mode 100644 index 0000000..6c560d1 --- /dev/null +++ b/services/presentation/app/static/wyloguj.js @@ -0,0 +1,44 @@ +// Wylogowanie z HTTP Basic (LOG-32). +// +// PROBLEM, KTÓREGO NIE DA SIĘ ROZWIĄZAĆ PO STRONIE SERWERA. Basic nie ma sesji: +// przeglądarka zapamiętuje login i hasło, po czym dosyła je SAMA przy każdym +// żądaniu. Serwer nie ma czego „zapomnieć" — następne kliknięcie przyszłoby +// z kompletem poświadczeń i weszłoby z powrotem. +// +// Działa natomiast doprowadzenie do tego, żeby PRZEGLĄDARKA porzuciła to, co +// zapamiętała: żądanie z celowo błędnymi danymi, którym nadpisuje ona swój wpis +// dla tej witryny. To jest zachowanie powszechne w przeglądarkach, ale NIE +// ZAPISANE w żadnym standardzie — dlatego jest tu jako uzupełnienie, a nie +// jedyny mechanizm: sama trasa /wyloguj odpowiada 401 i wymusza okienko +// logowania nawet przy wyłączonym JavaScripcie. +(function () { + 'use strict'; + + function ready(fn) { + if (document.readyState !== 'loading') fn(); + else document.addEventListener('DOMContentLoaded', fn); + } + + ready(function () { + var link = document.querySelector('a[data-wyloguj]'); + if (!link) return; + + link.addEventListener('click', function (e) { + e.preventDefault(); + // Znacznik czasu w loginie: gdyby przeglądarka zapamiętała nawet TE dane, + // przy kolejnym wylogowaniu byłyby już inne, więc nie da się przypadkiem + // trafić w cokolwiek działającego. + var bezsensowne = 'Basic ' + btoa('wyloguj:' + Date.now()); + fetch(link.href, { + headers: {Authorization: bezsensowne}, + cache: 'no-store', + credentials: 'omit' + }).catch(function () { + // Brak sieci nie może zablokować wyjścia — i tak przechodzimy dalej, + // bo sama trasa /wyloguj odpowie 401 i wywoła okienko logowania. + }).then(function () { + window.location.href = link.href; + }); + }); + }); +})(); diff --git a/services/presentation/app/templates/base.html b/services/presentation/app/templates/base.html index 8870ae2..8e9ff00 100644 --- a/services/presentation/app/templates/base.html +++ b/services/presentation/app/templates/base.html @@ -13,6 +13,9 @@ {# Powiększanie kosmogramu (PRE-25) — globalnie, bo koło pojawi się też na zakładce „Skompiluj"; skrypt sam sprawdza, czy jest co powiększać. #} + {# Wylogowanie z Basic wymaga kroku po stronie przeglądarki — patrz komentarz + w wyloguj.js. Bez JavaScriptu link nadal działa, tylko słabiej. #} + @@ -29,6 +32,15 @@ {% if can(request, 'admin') %} Konta {% endif %} + {# Wyjście na końcu paska i odsunięte: to nie jest zakładka, tylko + czynność — pomylenie go z zakładką kosztuje przypadkowe wylogowania. + Pokazujemy też KTO jest zalogowany, bo bez tego przycisk wyjścia jest + w połowie bezużyteczny: na wspólnym komputerze nie wiadomo, kogo się + wylogowuje. Bez włączonego logowania nie ma z czego wychodzić. #} + {% if auth_on() %} + {{ current_user(request) }} + Wyloguj + {% endif %} {% block content %}{% endblock %} diff --git a/services/presentation/tests/test_kontrola_dostepu.py b/services/presentation/tests/test_kontrola_dostepu.py index c5c1efe..5d2514f 100644 --- a/services/presentation/tests/test_kontrola_dostepu.py +++ b/services/presentation/tests/test_kontrola_dostepu.py @@ -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ć"