Files
astrololo/services/presentation/app/session.py
T
gitea f0d07ee8c3
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m17s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m30s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 5s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 4s
build / build (push) Successful in 7s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m18s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 6s
Testy / Kontrola składni wszystkich warstw (push) Successful in 4s
feat(bezpieczeństwo): sesje logowania zamiast HTTP Basic (LOG-34)
Basic nie miał wylogowania i nie dało się tego obejść: przeglądarka zapamiętuje
hasło i dosyła je SAMA przy każdym żądaniu, więc serwer nie ma czego zapomnieć.
Poprzednia próba (LOG-32) opierała się na nakłonieniu przeglądarki, żeby porzuciła
zapamiętane dane — zachowaniu powszechnym, ale nigdzie nie zapisanym. Teraz to
serwer decyduje, czy dana przeglądarka jest w środku, i może to cofnąć.

TRZY POZIOMY UNIEWAŻNIENIA, celowo rozdzielone, bo każdy kosztuje co innego:
  1. wylogowanie = skasowanie ciasteczka. Natychmiastowe, bez magazynu.
  2. zmiana hasła albo skasowanie konta = odcisk poświadczenia wpisany
     w ciasteczko przestaje pasować. Dzieje się SAMO, bez pamiętania o tym.
     Bez tego odebranie komuś dostępu nie odbierałoby dostępu aż do wygaśnięcia.
  3. „zamknij sesje" z ekranu kont = licznik pokolenia. Jedyny wymagający zapisu,
     więc jedyny opcjonalny: gdy licznika nie ma, poziomy 1 i 2 nadal działają.

KONTO ADMINISTRACYJNE ODSEPAROWANE. Sprawdzane pierwsze i BEZ DOTYKANIA pliku
kont, co daje dwie rzeczy naraz: konto z pliku o tym samym loginie nie przesłoni
administratora, a administrator zaloguje się także wtedy, gdy plik jest uszkodzony
— 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ść. Jego odpowiednikiem „wyloguj zewsząd" jest zmiana APP_PASSWORD.

KLUCZ WYMAGANY, FAIL-CLOSED. Usługa z kontami, ale bez klucza podpisu, nie
odróżniłaby ważnej sesji od podrobionej, więc nie wstaje — i lepiej przy starcie
niż przy pierwszym logowaniu człowieka. Losowanie klucza byłoby wygodne, ale
wylogowywałoby wszystkich przy każdym restarcie poda: wygląda jak awaria i uczy
ludzi ignorować ekran logowania.

Ciasteczko HttpOnly (jeden wstrzyknięty skrypt inaczej wynosi sesję) i
SameSite=Strict (obca strona nie zadziała w imieniu zalogowanego). Wylogowanie
POST-em, nie odsyłaczem: pod adresem GET wystarczyłby obrazek na obcej stronie.
Adres powrotu po zalogowaniu przechodzi przez sito — bez tego `?dokad=https://obcy`
zamieniłby nasz ekran logowania w narzędzie do wyłudzania haseł.

Kres bezczynności 8 h i twardy 30 dni. Znacznik aktywności odświeżany z progiem,
inaczej Set-Cookie leciałby przy każdym obrazku i arkuszu stylów.

Zdarzenia logowania w dzienniku (PRE-17). Nieudane próby są tam ważniejsze od
udanych: pojedyncza nic nie znaczy, ale seria pod jednym adresem to jedyny
widoczny ślad zgadywania haseł. Test pilnuje, że hasło tam nie trafia.

Sprawdzone w przeglądarce: po zalogowaniu ciasteczko jest NIEWIDOCZNE dla
JavaScriptu, a po wylogowaniu wejście na chronioną stronę ląduje na ekranie
logowania — czyli dokładnie to, czego Basic nie potrafił.

Testy: 21 na rdzeń podpisywania (w tym podrabianie ładunku, podpisu i klucza,
ciasteczko z przyszłości, śmieci na wejściu), reszta przepisana z Basic na sesje.
Prezentacja 338 zielonych.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:18:15 +02:00

184 lines
7.3 KiB
Python

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