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 %}
+
+
+
+
+ Nie masz konta albo nie pamiętasz hasła? Zgłoś się do osoby, która zakładała
+ Ci dostęp — kont nie da się odzyskać samodzielnie.
+
+
+
+
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}"