feat(astroklient-demo): sesje logowania zamiast HTTP Basic (LOG-34)

Demo idzie szeroko i do różnych osób, często na cudzych komputerach — więc
wyjście z aplikacji jest tu potrzebne bardziej niż w pełnej wersji, a Basic go
nie miał: przeglądarka zapamiętuje hasło i dosyła je sama przy każdym żądaniu.
Pierwszy klient zostawiał otwartą sesję drugiemu.

Ta sama konstrukcja co w pełnej aplikacji: własny ekran logowania, podpisane
ciasteczko (HMAC-SHA256), HttpOnly + SameSite=Strict, wylogowanie POST-em, kres
bezczynności i twardy, sito na adres powrotu, zdarzenia w dzienniku bez haseł.
Moduł session.py skopiowany, tak samo jak link_crypto — usługi są osobnymi
obrazami i nie importują się nawzajem.

DWIE RÓŻNICE WOBEC PEŁNEJ WERSJI, obie wynikające z tego, że demo nie ma
własnego wolumenu:

  * nazwa ciasteczka jest inna. Gdyby obie aplikacje stanęły kiedyś pod jedną
    domeną, ciasteczka o tej samej nazwie nadpisywałyby się i człowiek wypadałby
    z jednej, logując się do drugiej.
  * nie ma licznika pokolenia sesji, bo nie ma go gdzie zapisać. Zdalne
    unieważnienie robi się przez DEMO_USERS: usunięcie konta albo zmiana hasła
    NATYCHMIAST ubija jego otwarte sesje, bo odcisk poświadczenia w ciasteczku
    przestaje pasować. Osobny test tego pilnuje. Wylogowanie i tak działa
    natychmiast, bo polega na skasowaniu ciasteczka.

Klucz podpisu jest WŁASNY, nie ten z pełnej aplikacji: demo i produkcja nie mają
powodu uznawać nawzajem swoich sesji, a wspólny klucz znaczyłby, że sesja z demo
bywa ważna tam, gdzie nie powinna.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 00:21:52 +02:00
parent 16cf25468f
commit c73ba6a964
7 changed files with 452 additions and 32 deletions
@@ -14,6 +14,8 @@ import base64
import pytest
from app import security
@pytest.fixture()
def env(monkeypatch):
@@ -21,11 +23,20 @@ def env(monkeypatch):
monkeypatch.delenv("DEMO_USER", raising=False)
monkeypatch.delenv("DEMO_PASSWORD", 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
def _auth(user="demo", password="tajne-demo"):
return {"Authorization": "Basic " + base64.b64encode(
f"{user}:{password}".encode()).decode()}
def _auth(user="demo", password=""):
"""Nagłówek z WAŻNĄ SESJĄ — odpowiednik bycia zalogowanym.
Po przejściu z Basic na sesje (LOG-34) „zalogowany" znaczy „ma podpisane
ciasteczko", nie „ma nagłówek z hasłem". Hasło przyjmowane tylko po to, żeby
nie przepisywać wszystkich wywołań — sprawdzanie poświadczeń ma własne testy."""
from app import security, session
return {"Cookie": f"{session.COOKIE}={security.issue_session(user)}"}
REPORT = {
@@ -75,7 +86,8 @@ def test_the_service_exposes_nothing_beyond_its_two_functions():
trasy = {(m, r.path) for r in app.routes for m in (getattr(r, "methods", None) or set())
if not r.path.startswith("/static") and m in {"GET", "POST"}}
assert trasy == {("GET", "/"), ("POST", "/"), ("POST", "/plik"), ("GET", "/health")}
assert trasy == {("GET", "/"), ("POST", "/"), ("POST", "/plik"), ("GET", "/health"),
("GET", "/logowanie"), ("POST", "/logowanie"), ("POST", "/wyloguj")}
def test_the_client_can_only_ask_for_what_the_demo_needs():
@@ -99,7 +111,9 @@ def test_api_docs_are_off():
def test_without_credentials_nothing_is_served(env, monkeypatch):
c, _ = _client(monkeypatch)
for method, path in (("get", "/"), ("post", "/"), ("post", "/plik")):
assert getattr(c, method)(path).status_code == 401, path
r = getattr(c, method)(path, follow_redirects=False)
assert r.status_code == 303, path
assert "/logowanie" in r.headers["location"], path
assert c.get("/health").status_code == 200, "sonda musi działać bez hasła"
@@ -110,15 +124,14 @@ def test_demo_account_is_separate_from_the_main_application(env, monkeypatch):
monkeypatch.setenv("APP_USER", "astrololo")
monkeypatch.setenv("APP_PASSWORD", "haslo-glownej-aplikacji")
c, _ = _client(monkeypatch)
assert c.get("/", headers=_auth("astrololo", "haslo-glownej-aplikacji")).status_code == 401
assert c.get("/", headers=_auth()).status_code == 200
assert security.verify("astrololo", "haslo-glownej-aplikacji") is None
assert security.verify("demo", "tajne-demo") == "demo"
def test_a_password_with_polish_letters_is_refused_not_crashed(env, monkeypatch):
monkeypatch.setenv("DEMO_USERS", "demo:zażółć-gęślą")
c, _ = _client(monkeypatch)
assert c.get("/", headers=_auth("demo", "złe-hasło")).status_code == 401
assert c.get("/", headers=_auth("demo", "zażółć-gęślą")).status_code == 200
# ── funkcja 1: dodanie pliku ─────────────────────────────────────────────
@@ -241,6 +254,44 @@ def test_every_account_has_its_own_pool_by_construction(env, monkeypatch):
def test_an_unknown_account_gets_nothing(env, monkeypatch):
"""Bez ważnej sesji żądanie nie schodzi niżej — nie wystarczy, że skończy
się przekierowaniem: nie może po drodze dotknąć warstwy logicznej."""
c, calls = _client(monkeypatch)
assert c.post("/", headers=_auth("obcy", "cokolwiek"), data=FORM).status_code == 401
assert calls == [], "poszło zapytanie mimo odmowy logowania"
r = c.post("/", data=FORM, follow_redirects=False)
assert r.status_code == 303 and "/logowanie" in r.headers["location"]
assert calls == [], "poszło zapytanie mimo braku sesji"
def test_logging_in_and_out_of_the_demo(env, monkeypatch):
"""Pełny obieg: demo bywa używane na cudzych komputerach, więc wyjście musi
naprawdę kończyć sesję, a nie tylko tak wyglądać."""
from app import session
c, _ = _client(monkeypatch)
r = c.post("/logowanie", follow_redirects=False,
data={"login": "klientA", "haslo": "hasloA", "dokad": "/"})
assert r.status_code == 303 and session.COOKIE in r.cookies
wyjscie = c.post("/wyloguj", headers=_auth("klientA"), follow_redirects=False)
assert wyjscie.status_code == 303
ustawione = wyjscie.headers.get("set-cookie", "")
assert session.COOKIE in ustawione and "Max-Age=0" in ustawione
def test_removing_an_account_kills_its_open_session(env, monkeypatch):
"""Demo nie ma gdzie trwale zapisywać stanu, więc zdalne unieważnienie robi
się przez DEMO_USERS — usunięcie albo zmiana hasła ubija sesję odciskiem."""
c, _ = _client(monkeypatch)
ciasteczko = _auth("klientA")
assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 200
monkeypatch.setenv("DEMO_USERS", "demo:tajne-demo,klientB:hasloB") # bez klientA
assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 303
def test_the_demo_login_cannot_be_turned_into_a_redirector(env, monkeypatch):
c, _ = _client(monkeypatch)
for zly in ("https://obcy.pl", "//obcy.pl", "http://obcy.pl/x"):
r = c.post("/logowanie", follow_redirects=False,
data={"login": "demo", "haslo": "tajne-demo", "dokad": zly})
assert r.headers["location"] == "/", f"przepuszczono: {zly}"