"""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: """Parametry 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": "/"}