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
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:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 = (
|
||||
'<!DOCTYPE html><html lang="pl"><head><meta charset="utf-8">'
|
||||
'<title>astrololo · wylogowano</title>'
|
||||
f'<link rel="stylesheet" href="{static_url("styles.css")}"></head>'
|
||||
'<body><main><header class="topbar"><h1>astrololo</h1></header>'
|
||||
'<p class="done-note">Wylogowano.</p>'
|
||||
'<p class="muted">Aby wrócić, <a href="/">zaloguj się ponownie</a>.</p>'
|
||||
'<p class="muted small">Jeśli komputer jest współdzielony, zamknij okno '
|
||||
'przeglądarki — to jedyny sposób pewny w każdej przeglądarce.</p>'
|
||||
'</main></body></html>'
|
||||
)
|
||||
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"}
|
||||
|
||||
@@ -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]] = {}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -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ć. #}
|
||||
<script defer src="{{ static('wheelzoom.js') }}"></script>
|
||||
{# Wylogowanie z Basic wymaga kroku po stronie przeglądarki — patrz komentarz
|
||||
w wyloguj.js. Bez JavaScriptu link nadal działa, tylko słabiej. #}
|
||||
<script defer src="{{ static('wyloguj.js') }}"></script>
|
||||
<script defer src="{{ static('wheeltip.js') }}"></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -29,6 +32,15 @@
|
||||
{% if can(request, 'admin') %}
|
||||
<a href="/accounts" class="{{ 'active' if nav_active is defined and nav_active == 'accounts' else '' }}">Konta</a>
|
||||
{% 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() %}
|
||||
<span class="whoami" title="Zalogowano jako">{{ current_user(request) }}</span>
|
||||
<a href="/wyloguj" class="wyloguj" data-wyloguj>Wyloguj</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</header>
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
@@ -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ć"
|
||||
|
||||
Reference in New Issue
Block a user