feat(bezpieczeństwo): sesje logowania zamiast HTTP Basic (LOG-34)
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
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
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>
This commit was merged in pull request #76.
This commit is contained in:
@@ -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": "/"}
|
||||
Reference in New Issue
Block a user