diff --git a/services/astroklient-demo/app/main.py b/services/astroklient-demo/app/main.py index bb011cd..14a1d10 100644 --- a/services/astroklient-demo/app/main.py +++ b/services/astroklient-demo/app/main.py @@ -27,7 +27,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from app import link_crypto, security +from app import link_crypto, security, session from app.clients.logic_client import LogicClient # Automatyczna dokumentacja WYŁĄCZONA — tak samo jak w pełnej aplikacji. /docs @@ -37,6 +37,8 @@ security.install(app) app.mount("/static", StaticFiles(directory="app/static"), name="static") templates = Jinja2Templates(directory="app/templates") +templates.env.globals["current_user"] = lambda request: getattr(request.state, "user", "") or "" +templates.env.globals["auth_on"] = security.auth_enabled def _logic(request: Request) -> LogicClient: """Klient związany z pulą ZALOGOWANEGO konta. @@ -150,6 +152,59 @@ def _wroc(error: str = "", done: str = "") -> RedirectResponse: return RedirectResponse(f"/{'?' + q if q else ''}", status_code=303) +# ---------------- Logowanie i wylogowanie (LOG-34) ---------------- +# Ta sama konstrukcja co w pełnej aplikacji. Różnica jest jedna i wynika z tego, +# że demo nie ma własnego wolumenu: nie ma licznika pokolenia sesji, więc +# „wyrzuć zewsząd" robi się zmianą hasła w DEMO_USERS. Wylogowanie i tak działa +# natychmiast, bo polega na skasowaniu ciasteczka. + +def _bezpieczne_dokad(dokad: str) -> str: + """Adres powrotu przepuszczony przez sito — bez tego `?dokad=https://obcy.pl` + zamieniłby ekran logowania w narzędzie do wyłudzania haseł.""" + dokad = (dokad or "").strip() + if not dokad.startswith("/") or dokad.startswith("//") or "\\" in dokad: + return "/" + return dokad.split("?", 1)[0].split("#", 1)[0] or "/" + + +@app.get("/logowanie", response_class=HTMLResponse) +def logowanie_form(request: Request, dokad: str = "/", blad: str = ""): + if security.auth_enabled() and security.principal(request): + return RedirectResponse(_bezpieczne_dokad(dokad), status_code=303) + return templates.TemplateResponse(request, "logowanie.html", + {"dokad": _bezpieczne_dokad(dokad), "blad": blad}) + + +@app.post("/logowanie") +def logowanie(request: Request, login: str = Form(""), haslo: str = Form(""), + dokad: str = Form("/")): + cel = _bezpieczne_dokad(dokad) + kto = security.verify(login.strip(), haslo) + if kto is None: + security.audit_login(request, login.strip(), udane=False) + return templates.TemplateResponse( + request, "logowanie.html", + {"dokad": cel, "blad": "Nieprawidłowy login lub hasło.", "login": login}, + status_code=401) + security.audit_login(request, kto, udane=True) + odpowiedz = RedirectResponse(cel, status_code=303) + odpowiedz.set_cookie(session.COOKIE, security.issue_session(kto), + max_age=session.max_age(), + **session.cookie_params(secure=security.cookies_secure())) + return odpowiedz + + +@app.post("/wyloguj") +def wyloguj(request: Request): + """POST, nie odsyłacz: pod adresem GET wystarczyłby obrazek na obcej stronie, + żeby wylogować kogoś bez jego wiedzy.""" + security.audit_logout(request) + odpowiedz = RedirectResponse("/logowanie?wylogowano=1", status_code=303) + odpowiedz.delete_cookie(session.COOKIE, + **session.cookie_params(secure=security.cookies_secure())) + return odpowiedz + + @app.get("/health") def health() -> dict: return {"status": "ok", "layer": "astroklient-demo"} diff --git a/services/astroklient-demo/app/security.py b/services/astroklient-demo/app/security.py index b8b1fe1..00f16db 100644 --- a/services/astroklient-demo/app/security.py +++ b/services/astroklient-demo/app/security.py @@ -24,7 +24,7 @@ import time from collections import deque from fastapi import Request -from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse log = logging.getLogger("astroklient-demo.security") audit_log = logging.getLogger("astroklient-demo.audit") @@ -32,7 +32,9 @@ audit_log = logging.getLogger("astroklient-demo.audit") _SCRYPT_N, _SCRYPT_R, _SCRYPT_P, _SCRYPT_LEN = 2**14, 8, 1, 32 _HASH_PREFIX = "scrypt$" -PUBLIC_PATHS = frozenset({"/health"}) +# /logowanie i /wyloguj poza bramką: inaczej żeby się zalogować, trzeba by być +# zalogowanym, a wyjście nie działałoby po wygaśnięciu sesji. +PUBLIC_PATHS = frozenset({"/health", "/logowanie", "/wyloguj"}) PUBLIC_PREFIXES = ("/static/",) _hits: dict[str, deque[float]] = {} @@ -92,20 +94,41 @@ def _secret_matches(spec: str, password: str) -> bool: return hmac.compare_digest(spec.encode("utf-8"), password.encode("utf-8")) -def authenticate(header: str | None) -> str | None: - if not header or not header.lower().startswith("basic "): - return None - try: - raw = base64.b64decode(header.split(" ", 1)[1]).decode("utf-8") - user, _, password = raw.partition(":") - except (binascii.Error, UnicodeDecodeError, IndexError): - return None - spec = accounts().get(user) +def verify(login: str, password: str) -> str | None: + """Sprawdza login i hasło; zwraca login albo None. + + Nieznany login i złe hasło traktujemy tak samo — odmowa nie mówi, które + konto istnieje, a przy demo rozdawanym szeroko to realna informacja.""" + spec = accounts().get(login) if spec is None: return None - # Nieznany login i złe hasło traktujemy tak samo — odmowa nie mówi, które - # konto istnieje, a przy demo rozdawanym szeroko to realna informacja. - return user if _secret_matches(spec, password) else None + return login if _secret_matches(spec, password) else None + + +def issue_session(login: str) -> str: + """Zawartość ciasteczka dla właśnie zalogowanego konta. + + W ciasteczku ląduje ODCISK poświadczenia, więc zmiana hasła albo usunięcie + konta z DEMO_USERS unieważnia jego sesje samo z siebie. To jest tu jedyny + sposób zdalnego unieważnienia: demo nie ma gdzie trwale zapisywać stanu, + bo nie ma własnego wolumenu — a stan sesji na udziale z bazami klientów + byłby pomieszaniem dwóch zupełnie różnych rzeczy.""" + from app import session + + return session.issue(login, session.fingerprint(accounts().get(login) or ""), 0) + + +def principal(request: Request) -> str | None: + """Ciasteczko → login zalogowanej osoby albo None.""" + from app import session + + dane = session.read(request.cookies.get(session.COOKIE)) + if dane is None: + return None + spec = accounts().get(dane["login"]) + if spec is None or session.fingerprint(spec) != dane["odcisk"]: + return None + return dane["login"] def client_ip(request: Request) -> str: @@ -159,8 +182,39 @@ def audit_records(request: Request, count: int) -> None: pass +def cookies_secure() -> bool: + """Ciasteczko tylko dla https. Wyłączane wyłącznie w pracy lokalnej po http — + inaczej przeglądarka po prostu go nie zapamięta i logowanie „nie działa" + bez żadnego komunikatu.""" + return os.getenv("COOKIES_INSECURE", "").strip().lower() not in {"1", "true", "yes", "on"} + + +def audit_login(request: Request, login: str, udane: bool) -> None: + """Zdarzenie logowania. Nieudane próby są ważniejsze od udanych: pojedyncza + nic nie znaczy, seria pod jednym adresem to ślad zgadywania haseł. Login tak, + hasło NIGDY.""" + audit_log.info("AUDYT zdarzenie=%s user=%s ip=%s", + "logowanie-ok" if udane else "logowanie-odmowa", + login or "-", client_ip(request)) + + +def audit_logout(request: Request) -> None: + audit_log.info("AUDYT zdarzenie=wylogowanie user=%s ip=%s", + getattr(request.state, "user", "") or "-", client_ip(request)) + + def install(app) -> None: + from app import session + _setup_audit_logging() + + # Fail-closed: konta bez klucza podpisu to sesje nie do odróżnienia od + # podrobionych. Lepiej, żeby usługa nie wstała. + if auth_enabled() and not session.available(): + raise session.SessionError( + "Konta demo są skonfigurowane, ale brakuje SESSION_SECRET — bez klucza " + "podpisu logowanie nie miałoby czym się bronić. openssl rand -hex 32") + if not auth_enabled(): log.warning( "UWAGA: brak kont (DEMO_USERS / DEMO_PASSWORD) — astroklient-demo stoi OTWARTY. " @@ -178,16 +232,27 @@ def install(app) -> None: return JSONResponse({"detail": "Zbyt wiele żądań — spróbuj za chwilę."}, status_code=429, headers={"Retry-After": "60"}) - user = "" + user, odswiez = "", None if auth_enabled(): - user = authenticate(request.headers.get("authorization")) or "" + user = principal(request) or "" if not user: - return HTMLResponse( - "

401 — wymagane logowanie

", status_code=401, - headers={"WWW-Authenticate": 'Basic realm="astroklient-demo"'}) + from urllib.parse import quote + + cel = request.url.path + dokad = f"?dokad={quote(cel, safe='/')}" if request.method == "GET" and cel != "/" else "" + return RedirectResponse(f"/logowanie{dokad}", status_code=303) + from app import session as _s + + dane = _s.read(request.cookies.get(_s.COOKIE)) + odswiez = _s.refresh(dane) if dane else None request.state.user = user response = await call_next(request) + if odswiez: + from app import session as _s + + response.set_cookie(_s.COOKIE, odswiez, max_age=_s.max_age(), + **_s.cookie_params(secure=cookies_secure())) records = getattr(request.state, "audit_records", None) audit_log.info( "AUDYT user=%s ip=%s method=%s path=%s status=%s records=%s ms=%.0f", diff --git a/services/astroklient-demo/app/session.py b/services/astroklient-demo/app/session.py new file mode 100644 index 0000000..47689d1 --- /dev/null +++ b/services/astroklient-demo/app/session.py @@ -0,0 +1,190 @@ +"""Sesje logowania — podpisane ciasteczko (LOG-34). + +Kopia modułu z warstwy prezentacji, tak samo jak link_crypto: usługi są osobnymi +obrazami i nie importują się nawzajem. Różni je WYŁĄCZNIE nazwa ciasteczka. + + +CO ZASTĘPUJE I DLACZEGO. HTTP Basic nie ma wylogowania: przeglądarka zapamiętuje +hasło i dosyła je SAMA przy każdym żądaniu, więc serwer nie ma czego zapomnieć. +Sesja odwraca tę zależność — to serwer decyduje, czy dana przeglądarka jest +w środku, i może tę decyzję cofnąć. + +TRZY POZIOMY UNIEWAŻNIENIA, celowo rozdzielone, bo każdy kosztuje co innego: + + 1. WYLOGOWANIE = skasowanie ciasteczka. Natychmiastowe, bez żadnego magazynu, + działa wszędzie. To jest to, czego oczekuje człowiek klikający „Wyloguj". + 2. ZMIANA POŚWIADCZEŃ = odcisk hasła wpisany w ciasteczko przestaje pasować. + Zmiana hasła albo skasowanie konta ubija jego sesje SAMO, bez pamiętania + o tym — i też nie wymaga magazynu. + 3. WYLOGOWANIE ZDALNE („wyrzuć ze wszystkich urządzeń") = licznik wersji, + który musi gdzieś leżeć. Wymaga zapisu, więc jest opcjonalny: gdy aplikacja + nie poda licznika, poziomy 1 i 2 nadal działają. + +Poziom 2 jest tu najważniejszy: bez niego odebranie komuś dostępu przez zmianę +hasła zostawiałoby jego otwartą sesję żywą aż do wygaśnięcia — czyli odebranie +dostępu nie odbierałoby dostępu. + +KLUCZ JEST WYMAGANY i bez niego usługa się nie uruchamia. Losowanie przy starcie +byłoby wygodne, ale oznaczałoby wylogowanie wszystkich przy każdym restarcie +poda — co wygląda jak awaria i uczy ludzi ignorować ekran logowania. + +Podpis: HMAC-SHA256 ze stdlib. Bez nowych zależności — ta sama linia co scrypt +w PRE-27 i AES w PRE-16. +""" +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import os +import time + +# Własna nazwa, nie ta z pełnej aplikacji: gdyby obie stały kiedyś pod jedną +# domeną, ciasteczka o tej samej nazwie nadpisywałyby się nawzajem i człowiek +# wypadałby z jednej aplikacji, logując się do drugiej. +COOKIE = "astroklient_sesja" + +# Twardy kres życia sesji i kres bezczynności. Oba w sekundach, oba z konfiguracji. +DEFAULT_MAX_AGE = 30 * 24 * 3600 # 30 dni — po tylu i tak każe się zalogować +DEFAULT_IDLE = 8 * 3600 # 8 godzin bez ruchu = koniec + +# Co ile odświeżamy znacznik aktywności w ciasteczku. Bez tego progu każde +# żądanie odsyłałoby Set-Cookie, także obrazki i arkusze stylów. +REFRESH_AFTER = 300 + + +class SessionError(RuntimeError): + """Sesje nie mogą działać — brak klucza albo klucz nie do przyjęcia.""" + + +def _env_int(name: str, default: int) -> int: + try: + return int(os.getenv(name, "") or default) + except ValueError: + return default + + +def max_age() -> int: + return _env_int("SESSION_MAX_AGE", DEFAULT_MAX_AGE) + + +def idle_timeout() -> int: + return _env_int("SESSION_IDLE", DEFAULT_IDLE) + + +def secret() -> bytes: + """Klucz podpisu. Brak = wyjątek, NIE cichy losowy klucz. + + Fail-closed jest tu celowe: usługa bez klucza nie umie odróżnić ważnej sesji + od podrobionej, więc lepiej, żeby nie wstała, niż żeby wpuszczała.""" + raw = os.getenv("SESSION_SECRET", "").strip() + if not raw: + raise SessionError( + "Brak SESSION_SECRET — bez klucza podpisu sesje nie mogą działać. " + "Wygeneruj: openssl rand -hex 32") + if len(raw) < 32: + raise SessionError( + f"SESSION_SECRET ma {len(raw)} znaków — za krótki. Wymagane co najmniej 32; " + f"krótki klucz da się odgadnąć, a wtedy podrobienie sesji jest trywialne.") + return raw.encode("utf-8") + + +def available() -> bool: + """Czy sesje da się w ogóle obsłużyć (do sprawdzenia przy starcie).""" + try: + secret() + except SessionError: + return False + return True + + +def fingerprint(secret_spec: str) -> str: + """Odcisk poświadczenia konta — 16 znaków hasza z hasła (albo jego hasza). + + NIE JEST to hasło ani nic, z czego da się je odtworzyć: bierzemy skrót + zapisanego sekretu, a ten dla kont zarządzanych sam jest już haszem scrypt. + Do ciasteczka trafia po to, żeby zmiana hasła albo skasowanie konta + unieważniły jego sesje same z siebie.""" + return hashlib.sha256(("odcisk:" + (secret_spec or "")).encode("utf-8")).hexdigest()[:16] + + +def _sign(payload: bytes) -> bytes: + return hmac.new(secret(), payload, hashlib.sha256).digest() + + +def _b64(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + +def _unb64(text: str) -> bytes: + return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) + + +def issue(login: str, odcisk: str, wersja: int = 0, *, now: float | None = None) -> str: + """Nowe ciasteczko sesji dla konta.""" + teraz = int(now if now is not None else time.time()) + payload = json.dumps( + {"u": login, "f": odcisk, "v": int(wersja), "wyd": teraz, "akt": teraz}, + separators=(",", ":"), sort_keys=True).encode("utf-8") + return f"{_b64(payload)}.{_b64(_sign(payload))}" + + +def read(token: str | None, *, now: float | None = None) -> dict | None: + """Ciasteczko → zawartość albo None, gdy cokolwiek się nie zgadza. + + None znaczy „nie wpuszczać" i nie mówi dlaczego — powód jest tu bez wartości + dla wołającego, a rozróżnianie „zła sygnatura" od „wygasło" tylko pomaga + zgadywać. Wygaśnięcie i tak kończy się tym samym ekranem logowania.""" + if not token or "." not in token: + return None + surowy, _, podpis = token.rpartition(".") + try: + payload = _unb64(surowy) + oczekiwany = _sign(payload) + podany = _unb64(podpis) + except (ValueError, SessionError): + return None + # Porównanie w stałym czasie: zwykłe == przecieka informację o tym, ile + # pierwszych bajtów podpisu się zgadza, a to wystarcza do podrobienia. + if not hmac.compare_digest(oczekiwany, podany): + return None + try: + dane = json.loads(payload.decode("utf-8")) + login, odcisk = str(dane["u"]), str(dane["f"]) + wersja, wyd, akt = int(dane["v"]), int(dane["wyd"]), int(dane["akt"]) + except (ValueError, KeyError, TypeError): + return None + + teraz = int(now if now is not None else time.time()) + # Ciasteczko z przyszłości oznacza przestawiony zegar albo podrobienie — + # w obu wypadkach nie ma powodu mu ufać. + if wyd > teraz + 60: + return None + if teraz - wyd > max_age() or teraz - akt > idle_timeout(): + return None + return {"login": login, "odcisk": odcisk, "wersja": wersja, "wydano": wyd, "aktywnosc": akt} + + +def refresh(dane: dict, *, now: float | None = None) -> str | None: + """Odświeżone ciasteczko, gdy znacznik aktywności zdążył się zestarzeć. + + None = nie ma potrzeby nic odsyłać. Bez tego progu każde żądanie zwracałoby + Set-Cookie — także po arkusze stylów i obrazki.""" + teraz = int(now if now is not None else time.time()) + if teraz - dane["aktywnosc"] < REFRESH_AFTER: + return None + payload = json.dumps( + {"u": dane["login"], "f": dane["odcisk"], "v": dane["wersja"], + "wyd": dane["wydano"], "akt": teraz}, + separators=(",", ":"), sort_keys=True).encode("utf-8") + return f"{_b64(payload)}.{_b64(_sign(payload))}" + + +def cookie_params(secure: bool = True) -> dict: + """Ustawienia ciasteczka wspólne dla ustawiania i kasowania. + + httponly — JavaScript nie ma po co go czytać, a bez tego jeden wstrzyknięty + skrypt wynosi sesję. samesite=strict — odcina większość ataków polegających + na tym, że obca strona wysyła żądanie w imieniu zalogowanego.""" + return {"httponly": True, "samesite": "strict", "secure": secure, "path": "/"} diff --git a/services/astroklient-demo/app/static/styles.css b/services/astroklient-demo/app/static/styles.css index e6f86c3..71a6bd7 100644 --- a/services/astroklient-demo/app/static/styles.css +++ b/services/astroklient-demo/app/static/styles.css @@ -11,7 +11,20 @@ body { font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; } main { max-width: 880px; margin: 0 auto; padding: 1.5rem 1rem 3rem; } -.topbar { padding: .5rem 0 1rem; border-bottom: 1px solid var(--line); } +.topbar { padding: .5rem 0 1rem; border-bottom: 1px solid var(--line); + display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; } +.kto { display: flex; align-items: center; gap: .5rem; } +.whoami { color: var(--muted); font-size: .85rem; } +.wyloguj-form { display: inline; margin: 0; } +button.wyloguj { + background: none; border: 1px solid var(--line); color: var(--muted); + font: inherit; font-weight: 400; padding: .35rem .8rem; border-radius: 8px; cursor: pointer; +} +button.wyloguj:hover { color: var(--fg); } + +/* Ekran logowania — wąski, bez niczego poza formularzem. */ +.login-card label { display: block; margin-top: .75rem; } +.login-card input { width: 100%; } h1 { margin: 0; font-size: 1.4rem; letter-spacing: .5px; } h2 { margin: 0 0 .3rem; font-size: 1.05rem; } h3 { margin: 0 0 .5rem; font-size: 1rem; } diff --git a/services/astroklient-demo/app/templates/base.html b/services/astroklient-demo/app/templates/base.html index b6955ba..ba0080f 100644 --- a/services/astroklient-demo/app/templates/base.html +++ b/services/astroklient-demo/app/templates/base.html @@ -10,6 +10,16 @@

astroklient-demo

+ {# Kto jest zalogowany i wyjście. Demo bywa używane na cudzych komputerach, + więc bez tego pierwszy klient zostawia otwartą sesję drugiemu. #} + {% if auth_on() %} +
+ {{ current_user(request) }} +
+ +
+
+ {% endif %} {# CELOWO BEZ NAWIGACJI. Dwie funkcje mieszczą się na jednym ekranie, a pasek zakładek z jedną pozycją tylko sugerowałby, że są inne. #}
diff --git a/services/astroklient-demo/app/templates/logowanie.html b/services/astroklient-demo/app/templates/logowanie.html new file mode 100644 index 0000000..cc41f83 --- /dev/null +++ b/services/astroklient-demo/app/templates/logowanie.html @@ -0,0 +1,36 @@ + + + + + + astroklient-demo · logowanie + + + +
+

astroklient-demo

+ + {% if request.query_params.get('wylogowano') %} +

Wylogowano.

+ {% endif %} + {% if blad %}

{{ blad }}

{% endif %} + + + + +
+ + diff --git a/services/astroklient-demo/tests/test_astroklient_demo.py b/services/astroklient-demo/tests/test_astroklient_demo.py index 1f69261..f2a2f97 100644 --- a/services/astroklient-demo/tests/test_astroklient_demo.py +++ b/services/astroklient-demo/tests/test_astroklient_demo.py @@ -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}"