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

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:
2026-08-21 00:18:15 +02:00
parent 71bb3b9c0a
commit f0d07ee8c3
16 changed files with 923 additions and 243 deletions
@@ -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