10970c579f
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m19s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m26s
Testy / Testy astrodemo (pull_request) Failing after 0s
Testy / Testy astroklient (pull_request) Successful in 9m29s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 7s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 5s
build / build (push) Successful in 19s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m25s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m26s
Testy / Testy astrodemo (push) Failing after 0s
Testy / Testy astroklient (push) Successful in 9m29s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 7s
Testy / Kontrola składni wszystkich warstw (push) Successful in 5s
Trzeci produkt drabiny: astrodemo (dwie funkcje) → astroklient → astrololo. Po trzech poprzednich krokach jest cienki, bo jest ZŁOŻENIEM, a nie kopią: własne main.py z ośmioma importami, a rdzeń — ekrany, szablony, zasoby — bierze z warstwy prezentacji przy budowaniu obrazu. Jedno źródło, dwa produkty; inaczej te same 2500 linii szablonów żyłyby w dwóch egzemplarzach i rozjechały się w ciągu tygodni, po cichu. CO MA: Horoskop, Interpretacje, Kalendarz, Synastria, Sygnifikatory, wgrywanie plików. Wyszukiwarka miejsca i strefa czasowa zgodnie z ustaleniem. CZEGO NIE MA I DLACZEGO NIE DA SIĘ WŁĄCZYĆ: plików usuniętych wg usun.txt nie ma w obrazie. Nie istnieje uprawnienie, którym dałoby się je odsłonić, bo katalog funkcji składa się ze ZGŁOSZEŃ ekranów obecnych w obrazie. To dlatego „każde konto dostaje wszystko, co ta usługa umie" jest tu bezpieczne i nie wymaga wypisywania listy: zbiór liczy się z katalogu, więc opisuje ten produkt. KONTA jak w astrodemo: z konfiguracji środowiska (ASTROKLIENT_USERS), jeden poziom dostępu, bez pliku kont i bez ekranu ich zakładania. Konta rozdziela się po to, żeby każde miało własną pulę plików. PULE PER KONTO — tu była realna dziura. Warstwa logiczna przenosiła pulę tylko przy raporcie i operacjach na plikach, więc Kalendarz i Sygnifikatory czytałyby CAŁY udział: jedno konto widziałoby pliki drugiego, mimo obietnicy izolacji. Domknięte: TimelineRequest i QueryRequest niosą teraz pulę, a QueryService buduje klienta danych na żądanie. Pula jedzie w każdym żądaniu w dół i bierze się z kontekstu ustawianego przy wejściu, nigdy z formularza. Test podstawia `tenant=ktos-inny` w POST i sprawdza, że w dół poszedł login zalogowanego. WARSTWA WSPÓLNA ROZDZIELONA OD POJĘCIA ADMINISTRATORA. base.html miał wpisany na sztywno warunek `can(request, 'admin')` i odsyłacz do ekranu kont — czyli w produkcie bez tego ekranu zostawał martwy link i nazwa czegoś, czego nie ma. Rejestr niesie teraz wymagane uprawnienie, a szablon dostaje gotową listę. Podstawa przestała też importować moduły służące jednemu ekranowi (konta, stany plików), bo produkt bez tego ekranu wlókł ich zależności. ZAPORA SŁOWNIKOWA NAD REALNYM DRZEWEM. Test buduje złożenie tak samo jak Dockerfile i szuka słów o funkcjach, których nie ma — w odpowiedziach ORAZ w plikach. Pierwsza wersja znalazła dziesięć trafień, w tym trzy moje własne docstringi WYLICZAJĄCE nieobecne funkcje: zdanie „nie ma tu generowania tekstu" mówi wprost, że coś takiego istnieje, więc jest takim samym śladem jak przycisk. Po poprawkach: zero. Test ma kontrolę negatywną — podrzucony plik ma go wywrócić. usun.txt jest DANYMI, nie tekstem w Dockerfile: czyta go też test pilnujący, żeby zgadzał się ze złożeniem w main.py. Rozjazd znaczyłby albo martwy kod w obrazie, albo błąd dopiero przy uruchomieniu. CI: astroklient buduje się z KORZENIA repozytorium (jego Dockerfile sięga po rdzeń), a zmiana w warstwie prezentacji też go przebudowuje — bez tego jego obraz zostawałby ze starymi ekranami, a różnicy nie byłoby widać do zgłoszenia użytkownika. Testy: astroklient 12, presentation 368, astrodemo 28, logic 342, data 42, render 41. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
184 lines
7.3 KiB
Python
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:
|
|
"""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": "/"}
|