diff --git a/docs/astrololo_wymagania.xlsx b/docs/astrololo_wymagania.xlsx index a847a59..656a3cf 100644 Binary files a/docs/astrololo_wymagania.xlsx and b/docs/astrololo_wymagania.xlsx differ diff --git a/services/presentation/app/accounts.py b/services/presentation/app/accounts.py index 6eed557..685bfc6 100644 --- a/services/presentation/app/accounts.py +++ b/services/presentation/app/accounts.py @@ -121,6 +121,7 @@ def all_users() -> dict[str, dict]: for login, row in _read()["users"].items(): out[login] = { "features": sorted(features.normalise(row.get("features"))), + "session_version": int(row.get("session_version") or 0), "note": str(row.get("note") or ""), "created": str(row.get("created") or ""), } @@ -131,6 +132,31 @@ def secret_of(login: str) -> str | None: return (_read()["users"].get(login) or {}).get("secret") +def session_version(login: str) -> int: + """Numer pokolenia sesji konta (LOG-34). + + Podbicie unieważnia WSZYSTKIE otwarte sesje tego konta, także na innych + urządzeniach — bez zmiany hasła. Zmiana hasła robi to samo niejako przy + okazji (zmienia się odcisk w ciasteczku), więc licznik jest potrzebny tylko + do „wyrzuć zewsząd, ale hasło zostaw".""" + try: + return int((_read()["users"].get(login) or {}).get("session_version") or 0) + except (TypeError, ValueError): + return 0 + + +def bump_session(login: str) -> int: + """Unieważnia wszystkie sesje konta. Podnosi ValueError, gdy konta nie ma.""" + with _lock: + data = _read() + row = data["users"].get(login) + if row is None: + raise ValueError(f"Nie ma konta „{login}”.") + row["session_version"] = session_version(login) + 1 + _write(data) + return row["session_version"] + + def permissions_of(login: str) -> frozenset[str]: return features.normalise((_read()["users"].get(login) or {}).get("features")) diff --git a/services/presentation/app/features.py b/services/presentation/app/features.py index 7fc1700..192097e 100644 --- a/services/presentation/app/features.py +++ b/services/presentation/app/features.py @@ -111,7 +111,10 @@ ROUTES: dict[tuple[str, str], str | 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, + ("GET", "/logowanie"): None, + ("POST", "/logowanie"): None, + ("POST", "/wyloguj"): None, + ("POST", "/accounts/sesje"): ADMIN, } diff --git a/services/presentation/app/main.py b/services/presentation/app/main.py index 4ef4c07..d3860ae 100644 --- a/services/presentation/app/main.py +++ b/services/presentation/app/main.py @@ -27,6 +27,7 @@ import base64 from app import accounts as accounts_store from app import chartwheel as chartwheel_mod from app import features +from app import session from app import files_state from app.house_systems import HOUSE_SYSTEMS, LIMITED as HOUSE_LIMITED, label as house_label @@ -873,6 +874,20 @@ def accounts_update(request: Request, login: str = Form(...), password: str = Fo return _accounts_redirect(done=f"Zapisano {changed} konta „{login}”.") +@app.post("/accounts/sesje") +def accounts_sesje(request: Request, login: str = Form(...)): + """Unieważnia WSZYSTKIE otwarte sesje konta, na każdym urządzeniu. + + Zmiana hasła robi to samo przy okazji, więc ta operacja jest na wypadek + „wyrzuć zewsząd, ale hasło zostaw" — na przykład gdy ktoś zostawił zalogowany + komputer i nie chce wpisywać nowego hasła wszędzie.""" + try: + accounts_store.bump_session(login) + except (ValueError, accounts_store.AccountsUnavailable) as e: + return _accounts_redirect(error=str(e)) + return _accounts_redirect(done=f"Zamknięto wszystkie sesje konta „{login}”.") + + @app.post("/accounts/delete") def accounts_delete(request: Request, login: str = Form(...)): try: @@ -882,46 +897,69 @@ 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. +# ---------------- Logowanie i wylogowanie (LOG-34) ---------------- +# Zastępuje HTTP Basic, który nie miał wylogowania: przeglądarka zapamiętywała +# hasło i dosyłała je sama, więc serwer nie miał czego zapomnieć. Teraz to serwer +# decyduje, czy dana przeglądarka jest w środku — i może to cofnąć. -@app.get("/wyloguj", response_class=HTMLResponse) -def wyloguj() -> HTMLResponse: - """Kończy sesję przeglądarki. Zawsze 401 — to jest tu WYNIK, nie awaria.""" - strona = ( - '' - 'astrololo · wylogowano' - f'' - '

astrololo

' - '

Wylogowano.

' - '

Aby wrócić, zaloguj się ponownie.

' - '

Jeśli komputer jest współdzielony, zamknij okno ' - 'przeglądarki — to jedyny sposób pewny w każdej przeglądarce.

' - '
' - ) - 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"}, - ) +def _bezpieczne_dokad(dokad: str) -> str: + """Adres powrotu po zalogowaniu, przepuszczony przez sito. + + Bez tego `?dokad=https://obcy.pl` zamieniłby nasz ekran logowania w narzędzie + do przekierowywania ludzi gdzie indziej — klasyczna droga do wyłudzenia hasła, + bo odsyłacz prowadzi z zaufanego adresu. Wpuszczamy wyłącznie ścieżki + wewnętrzne: jeden ukośnik na początku i ani jednego znaku, który mógłby + zacząć adres obcego serwera.""" + 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 = ""): + # Zalogowanego nie ma po co pytać o hasło jeszcze raz. + if security.auth_enabled() and security.principal(request) is not None: + 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) + who = security.verify(login.strip(), haslo) + if who is None: + # JEDEN komunikat na złe hasło i nieznany login. Rozróżnienie mówiłoby, + # które konta istnieją — a to informacja warta czegoś dla zgadującego. + 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, who.login, udane=True) + odpowiedz = RedirectResponse(cel, status_code=303) + odpowiedz.set_cookie(session.COOKIE, security.issue_session(who.login), + max_age=session.max_age(), + **session.cookie_params(secure=security.cookies_secure())) + return odpowiedz + + +@app.post("/wyloguj") +def wyloguj(request: Request): + """Kasuje ciasteczko sesji. To jest całe wylogowanie — natychmiastowe + i bez żadnego magazynu, bo przeglądarka nie ma już czego dosyłać. + + POST, nie GET: 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.""" + 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") diff --git a/services/presentation/app/security.py b/services/presentation/app/security.py index 4d9e166..a56b3a8 100644 --- a/services/presentation/app/security.py +++ b/services/presentation/app/security.py @@ -45,7 +45,7 @@ from collections import deque from dataclasses import dataclass from fastapi import Request -from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse log = logging.getLogger("astrololo.security") @@ -114,6 +114,15 @@ def rate_limit_per_min() -> int: return int(os.getenv("RATE_LIMIT_PER_MIN", "120")) +def cookies_secure() -> bool: + """Czy ciasteczko sesji ma być wyłącznie dla https. + + Domyślnie TAK. Wyłączamy jedynie w pracy lokalnej po http — inaczej + przeglądarka po prostu nie zapamięta ciasteczka i logowanie „nie działa" + bez żadnego komunikatu, co jest wyjątkowo mylącą awarią.""" + return os.getenv("COOKIES_INSECURE", "").strip().lower() not in {"1", "true", "yes", "on"} + + def trust_proxy() -> bool: return os.getenv("TRUST_PROXY", "").strip().lower() in {"1", "true", "yes", "on"} @@ -122,7 +131,9 @@ def trust_proxy() -> bool: # 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"}) +# /logowanie MUSI stać poza bramką — inaczej żeby się zalogować, trzeba by być +# zalogowanym. /wyloguj też: ma działać także wtedy, gdy sesja już wygasła. +PUBLIC_PATHS = frozenset({"/health", "/logowanie", "/wyloguj"}) PUBLIC_PREFIXES = ("/static/",) _hits: dict[str, deque[float]] = {} @@ -146,53 +157,99 @@ class Principal: def _admin_permissions() -> frozenset[str]: - """Administrator widzi wszystko — z definicji, bez wpisywania listy ręcznie. + """Administrator widzi wszystko — z definicji, bez wypisywania listy ręcznie. - Liczone z katalogu, więc dopisanie nowej funkcji NIE wymaga pamiętania - o koncie administracyjnym. Przeciwnie byłoby cichą pułapką: nowa funkcja - byłaby niewidoczna dla jedynej osoby, która ma ją komu nadać.""" + Liczone z katalogu, więc dopisanie funkcji NIE wymaga pamiętania o koncie + administracyjnym. Przeciwnie byłoby cichą pułapką: nowa funkcja byłaby + niewidoczna dla jedynej osoby, która ma ją komu nadać.""" from app import features return frozenset(features.GRANTABLE) | {features.ADMIN} -def principal(header: str | None) -> Principal | None: - """Nagłówek Basic → kto to jest i co mu wolno; None, gdy dane nie pasują. +def _admin_spec(login: str) -> str | None: + """Sekret konta administracyjnego z KONFIGURACJI ŚRODOWISKA albo None. - Kolejność: najpierw konta z KONFIGURACJI ŚRODOWISKA (administracyjne, pełne - uprawnienia), potem konta zarządzane z ekranu. Nie odwrotnie — inaczej - założenie w pliku konta o loginie administratora przesłoniłoby konto - administracyjne i odebrało mu uprawnienia. + Sprawdzane ZAWSZE PIERWSZE i bez dotykania pliku kont — to jest cała + separacja konta administracyjnego (LOG-34): - Nieznany login i złe hasło traktujemy tak samo: odmowa nie mówi, które konto - istnieje.""" - 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 + * konto z pliku o tym samym loginie nie przesłoni administratora, bo do + pliku w ogóle nie zaglądamy, gdy login pasuje do konfiguracji, + * administrator zaloguje się nawet wtedy, gdy plik kont jest uszkodzony + albo nieczytelny — 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ść. + """ + return accounts().get(login) - spec = accounts().get(user) + +def verify(login: str, password: str) -> Principal | None: + """Sprawdza login i hasło. None = odmowa, bez podawania powodu. + + Nieznany login i złe hasło dają ten sam wynik: rozróżnienie mówiłoby, które + konta istnieją, a to informacja warta czegoś dla zgadującego.""" + spec = _admin_spec(login) if spec is not None: - return (Principal(user, _admin_permissions(), True) - if _secret_matches(spec, password) else None) + return Principal(login, _admin_permissions(), True) if _secret_matches(spec, password) else None from app import accounts as store - managed = store.secret_of(user) - if managed is None: + try: + zapisany = store.secret_of(login) + except store.AccountsUnavailable: + # Magazyn kont niedostępny — konta zarządzane nie mogą się zalogować, + # ale administrator (wyżej) już przeszedł, więc jest kto to naprawi. return None - if not _secret_matches(managed, password): + if zapisany is None or not _secret_matches(zapisany, password): return None - return Principal(user, store.permissions_of(user), False) + return Principal(login, store.permissions_of(login), False) -def authenticate(header: str | None) -> str | None: - """Sam LOGIN — do dziennika audytowego (PRE-17) i zgodności wstecz.""" - who = principal(header) - return who.login if who else None +def issue_session(login: str) -> str: + """Zawartość ciasteczka dla właśnie zalogowanego konta. + + W ciasteczku ląduje ODCISK poświadczenia, dzięki czemu zmiana hasła albo + skasowanie konta unieważnia jego sesje same z siebie — bez pamiętania o tym + i bez żadnego magazynu sesji.""" + from app import accounts as store + from app import session + + spec = _admin_spec(login) + if spec is not None: + return session.issue(login, session.fingerprint(spec), 0) + return session.issue(login, session.fingerprint(store.secret_of(login) or ""), + store.session_version(login)) + + +def principal(request: Request) -> Principal | None: + """Ciasteczko → kto to jest i co mu wolno. None = nie wpuszczać.""" + from app import session + + dane = session.read(request.cookies.get(session.COOKIE)) + if dane is None: + return None + login = dane["login"] + + spec = _admin_spec(login) + if spec is not None: + # Zmiana APP_PASSWORD ubija sesje administratora — to jest jego + # odpowiednik „wyloguj mnie ze wszystkich urządzeń". + if session.fingerprint(spec) != dane["odcisk"]: + return None + return Principal(login, _admin_permissions(), True) + + from app import accounts as store + + try: + zapisany = store.secret_of(login) + wersja = store.session_version(login) + except store.AccountsUnavailable: + return None + if zapisany is None or session.fingerprint(zapisany) != dane["odcisk"]: + return None + if wersja != dane["wersja"]: + return None + return Principal(login, store.permissions_of(login), False) def client_ip(request: Request) -> str: @@ -270,6 +327,22 @@ def audit_records(request: Request, count: int) -> None: pass +def audit_login(request: Request, login: str, udane: bool) -> None: + """Zdarzenie logowania w dzienniku (PRE-17). + + Nieudane próby są tu ważniejsze od udanych: pojedyncza nic nie znaczy, ale + seria pod jednym adresem to jedyny widoczny ślad zgadywania haseł. Logujemy + LOGIN, nigdy hasła ani jego fragmentu — dziennik to kolejny nośnik wycieku.""" + 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 _audit(request: Request, user: str, status: int, ms: float) -> None: records = getattr(request.state, "audit_records", None) audit_log.info( @@ -280,8 +353,20 @@ def _audit(request: Request, user: str, status: int, ms: float) -> None: def install(app) -> None: - """Podpina ochronę pod wszystkie ścieżki poza /health i /static.""" + """Podpina ochronę pod wszystkie ścieżki poza publicznymi.""" + from app import session + _setup_audit_logging() + + # FAIL-CLOSED. Usługa z kontami, ale bez klucza podpisu, nie umiałaby odróżnić + # ważnej sesji od podrobionej. Lepiej, żeby nie wstała, niż żeby wpuszczała — + # i lepiej teraz, przy starcie, niż przy pierwszym logowaniu człowieka. + if auth_enabled() and not session.available(): + raise session.SessionError( + "Konta są skonfigurowane, ale brakuje SESSION_SECRET — bez klucza " + "podpisu sesje nie działają, a logowanie nie miałoby czym się bronić. " + "Wygeneruj: openssl rand -hex 32") + if not auth_enabled(): log.warning( "UWAGA: brak kont (APP_USERS / APP_PASSWORD) — aplikacja jest OTWARTA " @@ -311,16 +396,27 @@ def install(app) -> None: from app import features - user, who = "", None + user, who, odswiez = "", None, None if auth_enabled(): - who = principal(request.headers.get("authorization")) + who = principal(request) if who is None: - _audit(request, "-", 401, (time.monotonic() - started) * 1000) - return HTMLResponse( - "

401 — wymagane logowanie

", status_code=401, - headers={"WWW-Authenticate": 'Basic realm="astrololo"'}, - ) + _audit(request, "-", 303, (time.monotonic() - started) * 1000) + # Przekierowanie na ekran logowania, nie 401: 401 z Basic otwierało + # okienko systemowe, którego nie da się ani opisać, ani z niego wyjść. + # Zapamiętujemy, dokąd ktoś zmierzał — po zalogowaniu wraca na miejsce. + 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) user = who.login + # Ruch przedłuża sesję, ale ciasteczko odsyłamy tylko wtedy, gdy + # znacznik zdążył się zestarzeć — inaczej Set-Cookie leciałby przy + # każdym obrazku i arkuszu stylów. + from app import session as _s + + dane = _s.read(request.cookies.get(_s.COOKIE)) + odswiez = _s.refresh(dane) if dane else None else: # Bez kont ochrona jest wyłączona (dev), więc widać wszystko — inaczej # deweloper zobaczyłby pustą aplikację i szukałby błędu tam, gdzie go nie ma. @@ -336,5 +432,10 @@ def install(app) -> None: _audit(request, user, 404, (time.monotonic() - started) * 1000) return HTMLResponse("

404 — nie znaleziono

", status_code=404) 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())) _audit(request, user, response.status_code, (time.monotonic() - started) * 1000) return response diff --git a/services/presentation/app/session.py b/services/presentation/app/session.py new file mode 100644 index 0000000..7204858 --- /dev/null +++ b/services/presentation/app/session.py @@ -0,0 +1,183 @@ +"""Sesje logowania — podpisane ciasteczko (LOG-34). + +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 + +COOKIE = "astrololo_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/presentation/app/static/styles.css b/services/presentation/app/static/styles.css index ce121dc..e42efc9 100644 --- a/services/presentation/app/static/styles.css +++ b/services/presentation/app/static/styles.css @@ -271,5 +271,17 @@ tr.row-held td { background: rgba(184, 134, 11, .07); } 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); } +/* Wylogowanie idzie POST-em, więc jest przyciskiem — ale ma wyglądać jak + reszta paska, nie jak przycisk akcji w formularzu. */ +.wyloguj-form { display: inline; margin: 0; padding: 0; background: none; border: none; } +button.wyloguj { + background: none; border: 1px solid transparent; color: var(--muted); + font: inherit; padding: .4rem .8rem; border-radius: 8px; cursor: pointer; +} +button.wyloguj:hover { color: var(--ink); } + +/* Ekran logowania — wąski, bez nawigacji: nie ma dokąd iść przed zalogowaniem. */ +.login-main { max-width: 26rem; } +.login-card label { display: block; margin-top: .75rem; } +.login-card input { width: 100%; } +.login-note { margin-top: 1.25rem; } diff --git a/services/presentation/app/static/wyloguj.js b/services/presentation/app/static/wyloguj.js deleted file mode 100644 index 6c560d1..0000000 --- a/services/presentation/app/static/wyloguj.js +++ /dev/null @@ -1,44 +0,0 @@ -// 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; - }); - }); - }); -})(); diff --git a/services/presentation/app/templates/accounts.html b/services/presentation/app/templates/accounts.html index afba6b8..a8e1b39 100644 --- a/services/presentation/app/templates/accounts.html +++ b/services/presentation/app/templates/accounts.html @@ -53,6 +53,10 @@
+ + {% endif %} diff --git a/services/presentation/app/templates/logowanie.html b/services/presentation/app/templates/logowanie.html new file mode 100644 index 0000000..3862bc3 --- /dev/null +++ b/services/presentation/app/templates/logowanie.html @@ -0,0 +1,36 @@ + + + + + + astrololo · logowanie + + + +
+

astrololo

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

Wylogowano.

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

{{ blad }}

{% endif %} + + + + +
+ + diff --git a/services/presentation/tests/test_accounts_audit.py b/services/presentation/tests/test_accounts_audit.py index c0cb70f..1843b9e 100644 --- a/services/presentation/tests/test_accounts_audit.py +++ b/services/presentation/tests/test_accounts_audit.py @@ -5,7 +5,6 @@ krytyczne i CICHE, gdy się zepsują: (1) odebranie dostępu jednej osobie musi faktycznie działać, (2) dziennik musi mówić KTO i ILE, ale NIGDY nie powielać treści baz (byłby wtedy kolejnym nośnikiem wycieku). """ -import base64 import logging import pytest @@ -13,9 +12,13 @@ import pytest from app import security -def _basic(user: str, password: str) -> dict: - raw = base64.b64encode(f"{user}:{password}".encode()).decode() - return {"Authorization": f"Basic {raw}"} +def _kto(user: str, password: str) -> str | None: + """Login, gdy poświadczenia pasują; None przy odmowie. + + Po przejściu na sesje (LOG-34) sprawdzanie hasła robi `verify`, a nie + parsowanie nagłówka Basic — samego nagłówka nie ma już w ogóle.""" + who = security.verify(user, password) + return who.login if who else None @pytest.fixture(autouse=True) @@ -57,29 +60,29 @@ def test_named_accounts_are_parsed(monkeypatch): def test_login_with_hashed_password(monkeypatch): monkeypatch.setenv("APP_USERS", f"alicja:{security.hash_password('tajne')}") - assert security.authenticate(_basic("alicja", "tajne")["Authorization"]) == "alicja" - assert security.authenticate(_basic("alicja", "złe")["Authorization"]) is None + assert _kto("alicja", "tajne") == "alicja" + assert _kto("alicja", "złe") is None def test_unknown_user_is_denied(monkeypatch): monkeypatch.setenv("APP_USERS", "alicja:tajne") - assert security.authenticate(_basic("obcy", "tajne")["Authorization"]) is None + assert _kto("obcy", "tajne") is None def test_revoking_one_account_leaves_others_untouched(monkeypatch): """Sedno PRE-17: odcinamy jedną osobę BEZ zmiany hasła pozostałym.""" monkeypatch.setenv("APP_USERS", "alicja:tajne,bartek:inne") - assert security.authenticate(_basic("bartek", "inne")["Authorization"]) == "bartek" + assert _kto("bartek", "inne") == "bartek" monkeypatch.setenv("APP_USERS", "alicja:tajne") # usunięto wpis Bartka - assert security.authenticate(_basic("bartek", "inne")["Authorization"]) is None - assert security.authenticate(_basic("alicja", "tajne")["Authorization"]) == "alicja" + assert _kto("bartek", "inne") is None + assert _kto("alicja", "tajne") == "alicja" def test_shared_password_still_works_alone(monkeypatch): """Zgodność wstecz — dotychczasowe wdrożenie nie może paść po aktualizacji.""" monkeypatch.setenv("APP_USER", "astrololo") monkeypatch.setenv("APP_PASSWORD", "wspolne") - assert security.authenticate(_basic("astrololo", "wspolne")["Authorization"]) == "astrololo" + assert _kto("astrololo", "wspolne") == "astrololo" def test_named_accounts_disable_the_shared_password(monkeypatch): @@ -88,7 +91,7 @@ def test_named_accounts_disable_the_shared_password(monkeypatch): monkeypatch.setenv("APP_USERS", "alicja:tajne") monkeypatch.setenv("APP_USER", "astrololo") monkeypatch.setenv("APP_PASSWORD", "wspolne") - assert security.authenticate(_basic("astrololo", "wspolne")["Authorization"]) is None + assert _kto("astrololo", "wspolne") is None def test_no_accounts_means_auth_disabled(monkeypatch): @@ -97,28 +100,82 @@ def test_no_accounts_means_auth_disabled(monkeypatch): # ── dziennik audytowy ── -def _client(): +def _client(monkeypatch): + # Klucz MUSI być w środowisku ZANIM zaimportujemy app.main: security.install + # biegnie przy imporcie i bez klucza celowo nie pozwala usłudze wstać. + monkeypatch.setenv("SESSION_SECRET", "t" * 64) + monkeypatch.setenv("COOKIES_INSECURE", "1") + from starlette.testclient import TestClient from app.main import app - return TestClient(app) + return TestClient(app, follow_redirects=False) + + +class _dziennik: + """Przechwytuje wpisy audytowe. + + `caplog` podpina się pod korzeń, a dziennik audytowy ma celowo + `propagate=False` — żeby wpisy nie dublowały się w logach usługi. Trzeba więc + podpiąć uchwyt WPROST pod niego, inaczej test widzi pustkę i wygląda to na + brak wpisów zamiast na brak podsłuchu.""" + + def __init__(self, caplog): + self.caplog = caplog + + def __enter__(self): + security._setup_audit_logging() + security.audit_log.setLevel(logging.INFO) + security.audit_log.addHandler(self.caplog.handler) + return self + + def __exit__(self, *a): + security.audit_log.removeHandler(self.caplog.handler) + + @property + def tekst(self) -> str: + return "\n".join(r.getMessage() for r in self.caplog.records) + + +def _sesja(user: str) -> dict: + from app import session + + return {"Cookie": f"{session.COOKIE}={security.issue_session(user)}"} def test_audit_logs_who_and_how_many(monkeypatch, caplog): monkeypatch.setenv("APP_USERS", "alicja:tajne") - with caplog.at_level(logging.INFO, logger="astrololo.audit"): - _client().get("/synastry", headers=_basic("alicja", "tajne")) - entry = "\n".join(r.getMessage() for r in caplog.records) + c = _client(monkeypatch) + with _dziennik(caplog) as d: + c.get("/synastry", headers=_sesja("alicja")) + entry = d.tekst assert "user=alicja" in entry and "path=/synastry" in entry and "status=200" in entry -def test_audit_marks_failed_login_without_naming_the_account(monkeypatch, caplog): +def test_audit_marks_a_request_without_a_session(monkeypatch, caplog): + """Odesłanie na ekran logowania też zostawia ślad — bez tego seria prób + dobicia się do chronionej ścieżki byłaby w dzienniku niewidoczna.""" monkeypatch.setenv("APP_USERS", "alicja:tajne") - with caplog.at_level(logging.INFO, logger="astrololo.audit"): - _client().get("/synastry", headers=_basic("alicja", "złe")) - entry = "\n".join(r.getMessage() for r in caplog.records) - assert "status=401" in entry and "user=-" in entry + c = _client(monkeypatch) + with _dziennik(caplog) as d: + c.get("/synastry") + entry = d.tekst + assert "status=303" in entry and "user=-" in entry + + +def test_failed_login_is_logged_without_saying_which_account_exists(monkeypatch, caplog): + """Nieudane próby są w dzienniku ważniejsze od udanych: pojedyncza nic nie + znaczy, ale seria pod jednym adresem to jedyny widoczny ślad zgadywania + haseł. Hasło ani jego fragment NIE mogą tam trafić.""" + monkeypatch.setenv("APP_USERS", f"alicja:{security.hash_password('tajne')}") + c = _client(monkeypatch) + with _dziennik(caplog) as d: + r = c.post("/logowanie", data={"login": "alicja", "haslo": "zgaduje-haslo"}) + assert r.status_code == 401 + entry = d.tekst + assert "logowanie-odmowa" in entry + assert "zgaduje-haslo" not in entry, "hasło trafiło do dziennika" def test_audit_logger_actually_emits(monkeypatch): diff --git a/services/presentation/tests/test_kontrola_dostepu.py b/services/presentation/tests/test_kontrola_dostepu.py index 5d2514f..d548301 100644 --- a/services/presentation/tests/test_kontrola_dostepu.py +++ b/services/presentation/tests/test_kontrola_dostepu.py @@ -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 diff --git a/services/presentation/tests/test_pliki.py b/services/presentation/tests/test_pliki.py index 0bd3923..56b7416 100644 --- a/services/presentation/tests/test_pliki.py +++ b/services/presentation/tests/test_pliki.py @@ -21,14 +21,22 @@ def env(tmp_path, monkeypatch): monkeypatch.setenv("APP_PASSWORD", "tajne-szefa") monkeypatch.delenv("APP_USERS", 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 return tmp_path -def _auth(user, password): - import base64 +def _auth(user, password=""): + """Nagłówek z WAŻNĄ SESJĄ dla konta — odpowiednik bycia zalogowanym. - return {"Authorization": "Basic " + base64.b64encode( - f"{user}:{password}".encode()).decode()} + 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)}"} REGISTRY = [ diff --git a/services/presentation/tests/test_security.py b/services/presentation/tests/test_security.py index 92ec096..63590a9 100644 --- a/services/presentation/tests/test_security.py +++ b/services/presentation/tests/test_security.py @@ -1,11 +1,13 @@ -"""Logowanie do aplikacji i limit żądań (LOG-32). +"""Logowanie do aplikacji i limit żądań (LOG-32/LOG-34). To jest brama chroniąca oryginalne bazy interpretacyjne — bez niej każdy w sieci mógł je wypompować przez `/significators` czy generator promptu. Testy pilnują, że brama faktycznie zamyka, a nie tylko wygląda na zamkniętą. -""" -import base64 +Od LOG-34 bramą jest SESJA, nie HTTP Basic. Zmiana nie jest kosmetyczna: 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ąć. +""" import pytest from fastapi import FastAPI from fastapi.responses import HTMLResponse @@ -16,9 +18,11 @@ from app import security USER, PASSWORD = "astrololo", "haslo-testowe" -def _basic(user: str, password: str) -> dict[str, str]: - raw = base64.b64encode(f"{user}:{password}".encode()).decode() - return {"Authorization": f"Basic {raw}"} +def _sesja(user: str = USER) -> dict[str, str]: + """Nagłówek z ważną sesją — odpowiednik bycia zalogowanym.""" + from app import session + + return {"Cookie": f"{session.COOKIE}={security.issue_session(user)}"} def _app() -> FastAPI: @@ -47,48 +51,93 @@ def _reset_rate_limit(): def guarded(monkeypatch): monkeypatch.setenv("APP_PASSWORD", PASSWORD) monkeypatch.setenv("RATE_LIMIT_PER_MIN", "120") - return TestClient(_app()) + monkeypatch.setenv("SESSION_SECRET", "t" * 64) + monkeypatch.setenv("COOKIES_INSECURE", "1") # TestClient jedzie po http + return TestClient(_app(), follow_redirects=False) # ------------------------------------------------------------------ logowanie def test_blocks_anonymous_access(guarded): r = guarded.get("/significators") - assert r.status_code == 401 + assert r.status_code == 303 assert "treść z bazy" not in r.text -def test_challenges_with_basic_realm(guarded): - """Bez nagłówka WWW-Authenticate przeglądarka nie pokaże okna logowania.""" - assert "Basic" in guarded.get("/significators").headers.get("WWW-Authenticate", "") +def test_sends_people_to_the_login_screen_not_a_system_dialog(guarded): + """Basic odpowiadał 401 z WWW-Authenticate, co otwierało okienko systemowe: + nie dało się go opisać, ostylować ani z niego wyjść inaczej niż anulowaniem. + Ekran logowania jest zwykłą stroną, więc może wyjaśnić, co się stało.""" + r = guarded.get("/significators") + assert r.headers["location"].startswith("/logowanie") + assert "WWW-Authenticate" not in r.headers, "okienko systemowe wróciło" -def test_rejects_wrong_password(guarded): - assert guarded.get("/significators", headers=_basic(USER, "zle")).status_code == 401 +def test_remembers_where_someone_was_heading(guarded): + """Po zalogowaniu ma wrócić tam, gdzie szedł — nie na stronę główną.""" + assert "dokad=/significators" in guarded.get("/significators").headers["location"] -def test_rejects_wrong_user(guarded): - assert guarded.get("/significators", headers=_basic("obcy", PASSWORD)).status_code == 401 +def test_no_session_no_content(guarded): + """Popsute albo podrobione ciasteczko jest tym samym, co jego brak.""" + from app import session + + # Same znaki ASCII — nagłówek Cookie nie przenosi innych. + for zle in ("", "smiec", "a.b", "...", session.issue(USER, "zly-odcisk")): + r = guarded.get("/significators", headers={"Cookie": f"{session.COOKIE}={zle}"}) + assert r.status_code == 303, f"wpuszczono z ciasteczkiem: {zle!r}" -def test_rejects_malformed_header(guarded): - for bad in ("Basic !!!niebase64!!!", "Bearer cokolwiek", "", "Basic"): - assert guarded.get("/significators", headers={"Authorization": bad}).status_code == 401 - - -def test_allows_correct_credentials(guarded): - r = guarded.get("/significators", headers=_basic(USER, PASSWORD)) +def test_allows_a_valid_session(guarded): + r = guarded.get("/significators", headers=_sesja()) assert r.status_code == 200 assert "treść z bazy" in r.text +def test_credentials_are_checked_by_verify(monkeypatch): + """Samo sprawdzanie hasła — bez HTTP, bo to czysta funkcja.""" + monkeypatch.setenv("APP_PASSWORD", PASSWORD) + monkeypatch.delenv("APP_USERS", raising=False) + assert security.verify(USER, PASSWORD) is not None + assert security.verify(USER, "zle") is None + assert security.verify("obcy", PASSWORD) is None + + +def test_a_session_dies_when_the_password_changes(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 sesji.""" + monkeypatch.setenv("APP_PASSWORD", PASSWORD) + monkeypatch.setenv("SESSION_SECRET", "t" * 64) + monkeypatch.setenv("COOKIES_INSECURE", "1") + monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0") + client = TestClient(_app(), follow_redirects=False) + ciasteczko = _sesja() + assert client.get("/significators", headers=ciasteczko).status_code == 200 + + monkeypatch.setenv("APP_PASSWORD", "zupełnie-nowe-hasło") + assert client.get("/significators", headers=ciasteczko).status_code == 303 + + def test_health_stays_public(guarded): assert guarded.get("/health").status_code == 200 +def test_no_key_no_service(monkeypatch): + """Fail-closed: usługa z kontami, ale bez klucza podpisu, nie umiałaby + odróżnić ważnej sesji od podrobionej — więc nie wstaje.""" + from app import session + + monkeypatch.setenv("APP_PASSWORD", PASSWORD) + monkeypatch.delenv("SESSION_SECRET", raising=False) + with pytest.raises(session.SessionError): + _app() + + def test_open_when_password_unset(monkeypatch): - """Brak hasła = zgodność wstecz; ochrona wyłączona (i ostrzegamy przy starcie).""" + """Brak hasła = zgodność wstecz; ochrona wyłączona (i ostrzegamy przy starcie). + Klucz sesji nie jest wtedy potrzebny — nie ma czego podpisywać.""" monkeypatch.delenv("APP_PASSWORD", raising=False) + monkeypatch.delenv("SESSION_SECRET", raising=False) assert not security.auth_enabled() assert TestClient(_app()).get("/significators").status_code == 200 @@ -99,8 +148,9 @@ def test_rate_limit_blocks_flood(monkeypatch): """Masowe odpytywanie to droga eksfiltracji nawet po zalogowaniu.""" monkeypatch.setenv("APP_PASSWORD", PASSWORD) monkeypatch.setenv("RATE_LIMIT_PER_MIN", "5") + monkeypatch.setenv("SESSION_SECRET", "t" * 64) client = TestClient(_app()) - auth = _basic(USER, PASSWORD) + auth = _sesja() codes = [client.get("/significators", headers=auth).status_code for _ in range(8)] assert codes[:5] == [200] * 5 assert 429 in codes[5:] @@ -109,8 +159,9 @@ def test_rate_limit_blocks_flood(monkeypatch): def test_rate_limited_response_has_retry_after(monkeypatch): monkeypatch.setenv("APP_PASSWORD", PASSWORD) monkeypatch.setenv("RATE_LIMIT_PER_MIN", "1") + monkeypatch.setenv("SESSION_SECRET", "t" * 64) client = TestClient(_app()) - auth = _basic(USER, PASSWORD) + auth = _sesja() client.get("/significators", headers=auth) r = client.get("/significators", headers=auth) assert r.status_code == 429 and r.headers.get("Retry-After") == "60" @@ -121,7 +172,8 @@ def test_rate_limit_precedes_auth(monkeypatch): i sondowanie API jest darmowe.""" monkeypatch.setenv("APP_PASSWORD", PASSWORD) monkeypatch.setenv("RATE_LIMIT_PER_MIN", "3") - client = TestClient(_app()) + monkeypatch.setenv("SESSION_SECRET", "t" * 64) + client = TestClient(_app(), follow_redirects=False) codes = [client.get("/significators").status_code for _ in range(6)] assert 429 in codes diff --git a/services/presentation/tests/test_sesje.py b/services/presentation/tests/test_sesje.py new file mode 100644 index 0000000..5cfc3f1 --- /dev/null +++ b/services/presentation/tests/test_sesje.py @@ -0,0 +1,143 @@ +"""Sesje logowania — rdzeń podpisywania (LOG-34). + +Testy dzielą się na dwie grupy i ta druga jest ważniejsza: „czy działa" oraz +„czy da się oszukać". Kod, który tylko przechodzi tę pierwszą, wpuszcza każdego, +kto spróbuje czegoś innego niż zamierzony przebieg. +""" +import json +import time + +import pytest + +from app import session + +KLUCZ = "x" * 64 +ODCISK = session.fingerprint("scrypt$sól$hash") + + +@pytest.fixture() +def klucz(monkeypatch): + monkeypatch.setenv("SESSION_SECRET", KLUCZ) + monkeypatch.delenv("SESSION_MAX_AGE", raising=False) + monkeypatch.delenv("SESSION_IDLE", raising=False) + + +# ── klucz: fail-closed ─────────────────────────────────────────────────── + +def test_no_key_means_no_sessions_not_a_random_one(monkeypatch): + """Losowy klucz przy starcie wylogowywałby wszystkich przy każdym restarcie + poda — wygląda jak awaria i uczy ludzi ignorować ekran logowania.""" + monkeypatch.delenv("SESSION_SECRET", raising=False) + with pytest.raises(session.SessionError): + session.secret() + assert session.available() is False + + +def test_a_short_key_is_refused_with_a_reason(monkeypatch): + monkeypatch.setenv("SESSION_SECRET", "krótki") + with pytest.raises(session.SessionError) as e: + session.secret() + assert "32" in str(e.value) + + +# ── zwykły przebieg ────────────────────────────────────────────────────── + +def test_issue_and_read_round_trip(klucz): + dane = session.read(session.issue("ala", ODCISK, 3)) + assert dane["login"] == "ala" and dane["odcisk"] == ODCISK and dane["wersja"] == 3 + + +def test_fingerprint_never_carries_the_password(klucz): + """Odcisk jedzie do przeglądarki, więc nie może dać się cofnąć do hasła.""" + odcisk = session.fingerprint("tajne-hasło-użytkownika") + assert "tajne" not in odcisk and "hasło" not in odcisk + assert len(odcisk) == 16 + assert session.fingerprint("a") != session.fingerprint("b") + + +# ── próby oszukania ────────────────────────────────────────────────────── + +def test_a_tampered_payload_is_refused(klucz): + token = session.issue("ala", ODCISK) + surowy, _, podpis = token.rpartition(".") + dane = json.loads(session._unb64(surowy)) + dane["u"] = "szef" # podmiana konta + podrobiony = session._b64(json.dumps(dane, separators=(",", ":"), + sort_keys=True).encode()) + "." + podpis + assert session.read(podrobiony) is None + + +def test_a_tampered_signature_is_refused(klucz): + token = session.issue("ala", ODCISK) + surowy, _, podpis = token.rpartition(".") + assert session.read(f"{surowy}.{podpis[:-2]}xx") is None + + +def test_a_token_signed_with_another_key_is_refused(klucz, monkeypatch): + token = session.issue("ala", ODCISK) + monkeypatch.setenv("SESSION_SECRET", "y" * 64) + assert session.read(token) is None + + +@pytest.mark.parametrize("smiec", ["", None, "bez-kropki", ".", "a.b", + "!!!.???", "a" * 500]) +def test_garbage_never_raises_it_just_refuses(klucz, smiec): + """Wejście przychodzi z przeglądarki, więc wyjątek tutaj to 500 na każdym + żądaniu z popsutym ciasteczkiem — czyli aplikacja nie do odratowania + inaczej niż przez wyczyszczenie przeglądarki.""" + assert session.read(smiec) is None + + +def test_a_token_from_the_future_is_refused(klucz): + """Przestawiony zegar albo podrobienie — w obu razach nie ma powodu ufać.""" + przyszly = session.issue("ala", ODCISK, now=time.time() + 3600) + assert session.read(przyszly) is None + + +def test_reading_without_a_key_refuses_instead_of_exploding(klucz, monkeypatch): + token = session.issue("ala", ODCISK) + monkeypatch.delenv("SESSION_SECRET", raising=False) + assert session.read(token) is None + + +# ── wygasanie ──────────────────────────────────────────────────────────── + +def test_a_session_dies_of_old_age(klucz, monkeypatch): + monkeypatch.setenv("SESSION_MAX_AGE", "100") + token = session.issue("ala", ODCISK, now=time.time() - 200) + assert session.read(token) is None + + +def test_a_session_dies_of_inactivity(klucz, monkeypatch): + """Twardy kres jeszcze daleko, ale nikt nie ruszał — koniec.""" + monkeypatch.setenv("SESSION_MAX_AGE", "100000") + monkeypatch.setenv("SESSION_IDLE", "60") + stary = session.issue("ala", ODCISK, now=time.time() - 600) + assert session.read(stary) is None + + +def test_activity_extends_the_session_but_not_past_the_hard_limit(klucz, monkeypatch): + monkeypatch.setenv("SESSION_MAX_AGE", "1000") + monkeypatch.setenv("SESSION_IDLE", "100") + teraz = time.time() + token = session.issue("ala", ODCISK, now=teraz - 900) + odswiezony = session.refresh(session.read(token, now=teraz - 900), now=teraz - 10) + assert session.read(odswiezony, now=teraz) is not None, "ruch nie przedłużył sesji" + # ...ale twardego kresu nie przeskoczy + assert session.read(odswiezony, now=teraz + 200) is None + + +def test_refresh_stays_quiet_until_it_is_worth_it(klucz): + """Bez progu każde żądanie odsyłałoby Set-Cookie — także po arkusze stylów.""" + dane = session.read(session.issue("ala", ODCISK)) + assert session.refresh(dane) is None + assert session.refresh(dane, now=time.time() + session.REFRESH_AFTER + 1) is not None + + +# ── ustawienia ciasteczka ──────────────────────────────────────────────── + +def test_the_cookie_cannot_be_read_by_scripts_or_sent_cross_site(klucz): + p = session.cookie_params() + assert p["httponly"] is True, "jeden wstrzyknięty skrypt wyniósłby sesję" + assert p["samesite"] == "strict", "obca strona mogłaby działać w imieniu zalogowanego" + assert p["secure"] is True