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

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:
2026-08-21 00:18:15 +02:00
parent 71bb3b9c0a
commit f0d07ee8c3
16 changed files with 923 additions and 243 deletions
Binary file not shown.
+26
View File
@@ -121,6 +121,7 @@ def all_users() -> dict[str, dict]:
for login, row in _read()["users"].items(): for login, row in _read()["users"].items():
out[login] = { out[login] = {
"features": sorted(features.normalise(row.get("features"))), "features": sorted(features.normalise(row.get("features"))),
"session_version": int(row.get("session_version") or 0),
"note": str(row.get("note") or ""), "note": str(row.get("note") or ""),
"created": str(row.get("created") 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") 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]: def permissions_of(login: str) -> frozenset[str]:
return features.normalise((_read()["users"].get(login) or {}).get("features")) return features.normalise((_read()["users"].get(login) or {}).get("features"))
+4 -1
View File
@@ -111,7 +111,10 @@ ROUTES: dict[tuple[str, str], str | None] = {
("GET", "/health"): None, ("GET", "/health"): None,
# Wylogowanie musi być dostępne dla każdego zalogowanego, niezależnie od tego, # 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ść. # 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,
} }
+77 -39
View File
@@ -27,6 +27,7 @@ import base64
from app import accounts as accounts_store from app import accounts as accounts_store
from app import chartwheel as chartwheel_mod from app import chartwheel as chartwheel_mod
from app import features from app import features
from app import session
from app import files_state from app import files_state
from app.house_systems import HOUSE_SYSTEMS, LIMITED as HOUSE_LIMITED, label as house_label 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}”.") 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") @app.post("/accounts/delete")
def accounts_delete(request: Request, login: str = Form(...)): def accounts_delete(request: Request, login: str = Form(...)):
try: try:
@@ -882,46 +897,69 @@ def accounts_delete(request: Request, login: str = Form(...)):
return _accounts_redirect(done=f"Skasowano konto „{login}”.") return _accounts_redirect(done=f"Skasowano konto „{login}”.")
# ---------------- Wylogowanie (LOG-32) ---------------- # ---------------- Logowanie i wylogowanie (LOG-34) ----------------
# HTTP Basic NIE MA prawdziwego wylogowania i trzeba to powiedzieć wprost: # Zastępuje HTTP Basic, który nie miał wylogowania: przeglądarka zapamiętywała
# przeglądarka zapamiętuje dane logowania i dosyła je sama przy każdym żądaniu, # hasło i dosyłała je sama, więc serwer nie miał czego zapomnieć. Teraz to serwer
# więc „zapomnienie" po stronie serwera nic nie daje — kolejne kliknięcie weszłoby # decyduje, czy dana przeglądarka jest w środku — i może to cofnąć.
# 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.
@app.get("/wyloguj", response_class=HTMLResponse) def _bezpieczne_dokad(dokad: str) -> str:
def wyloguj() -> HTMLResponse: """Adres powrotu po zalogowaniu, przepuszczony przez sito.
"""Kończy sesję przeglądarki. Zawsze 401 — to jest tu WYNIK, nie awaria."""
strona = ( Bez tego `?dokad=https://obcy.pl` zamieniłby nasz ekran logowania w narzędzie
'<!DOCTYPE html><html lang="pl"><head><meta charset="utf-8">' do przekierowywania ludzi gdzie indziej — klasyczna droga do wyłudzenia hasła,
'<title>astrololo · wylogowano</title>' bo odsyłacz prowadzi z zaufanego adresu. Wpuszczamy wyłącznie ścieżki
f'<link rel="stylesheet" href="{static_url("styles.css")}"></head>' wewnętrzne: jeden ukośnik na początku i ani jednego znaku, który mógłby
'<body><main><header class="topbar"><h1>astrololo</h1></header>' zacząć adres obcego serwera."""
'<p class="done-note">Wylogowano.</p>' dokad = (dokad or "").strip()
'<p class="muted">Aby wrócić, <a href="/">zaloguj się ponownie</a>.</p>' if not dokad.startswith("/") or dokad.startswith("//") or "\\" in dokad:
'<p class="muted small">Jeśli komputer jest współdzielony, zamknij okno ' return "/"
'przeglądarki — to jedyny sposób pewny w każdej przeglądarce.</p>' return dokad.split("?", 1)[0].split("#", 1)[0] or "/"
'</main></body></html>'
)
return HTMLResponse( @app.get("/logowanie", response_class=HTMLResponse)
strona, status_code=401, def logowanie_form(request: Request, dokad: str = "/", blad: str = ""):
headers={"WWW-Authenticate": 'Basic realm="astrololo"', # Zalogowanego nie ma po co pytać o hasło jeszcze raz.
# Bez tego przeglądarka mogłaby oddać tę stronę z pamięci if security.auth_enabled() and security.principal(request) is not None:
# podręcznej przy kolejnym wylogowaniu, nie pytając serwera — return RedirectResponse(_bezpieczne_dokad(dokad), status_code=303)
# a wtedy okienko logowania w ogóle by się nie pojawiło. return templates.TemplateResponse(request, "logowanie.html",
"Cache-Control": "no-store, no-cache, must-revalidate", {"dokad": _bezpieczne_dokad(dokad), "blad": blad})
"Pragma": "no-cache"},
)
@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") @app.get("/health")
+141 -40
View File
@@ -45,7 +45,7 @@ from collections import deque
from dataclasses import dataclass from dataclasses import dataclass
from fastapi import Request from fastapi import Request
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
log = logging.getLogger("astrololo.security") log = logging.getLogger("astrololo.security")
@@ -114,6 +114,15 @@ def rate_limit_per_min() -> int:
return int(os.getenv("RATE_LIMIT_PER_MIN", "120")) 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: def trust_proxy() -> bool:
return os.getenv("TRUST_PROXY", "").strip().lower() in {"1", "true", "yes", "on"} 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 # 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 # 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ę. # żą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/",) PUBLIC_PREFIXES = ("/static/",)
_hits: dict[str, deque[float]] = {} _hits: dict[str, deque[float]] = {}
@@ -146,53 +157,99 @@ class Principal:
def _admin_permissions() -> frozenset[str]: 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 Liczone z katalogu, więc dopisanie funkcji NIE wymaga pamiętania o koncie
o koncie administracyjnym. Przeciwnie byłoby cichą pułapką: nowa funkcja administracyjnym. Przeciwnie byłoby cichą pułapką: nowa funkcja byłaby
byłaby niewidoczna dla jedynej osoby, która ma ją komu nadać.""" niewidoczna dla jedynej osoby, która ma ją komu nadać."""
from app import features from app import features
return frozenset(features.GRANTABLE) | {features.ADMIN} return frozenset(features.GRANTABLE) | {features.ADMIN}
def principal(header: str | None) -> Principal | None: def _admin_spec(login: str) -> str | None:
"""Nagłówek Basic → kto to jest i co mu wolno; None, gdy dane nie pasują. """Sekret konta administracyjnego z KONFIGURACJI ŚRODOWISKA albo None.
Kolejność: najpierw konta z KONFIGURACJI ŚRODOWISKA (administracyjne, pełne Sprawdzane ZAWSZE PIERWSZE i bez dotykania pliku kont — to jest cała
uprawnienia), potem konta zarządzane z ekranu. Nie odwrotnie — inaczej separacja konta administracyjnego (LOG-34):
założenie w pliku konta o loginie administratora przesłoniłoby konto
administracyjne i odebrało mu uprawnienia.
Nieznany login i złe hasło traktujemy tak samo: odmowa nie mówi, które konto * konto z pliku o tym samym loginie nie przesłoni administratora, bo do
istnieje.""" pliku w ogóle nie zaglądamy, gdy login pasuje do konfiguracji,
if not header or not header.lower().startswith("basic "): * administrator zaloguje się nawet wtedy, gdy plik kont jest uszkodzony
return None albo nieczytelny — czyli w jedynej sytuacji, w której ktoś musi wejść,
try: żeby to naprawić. Trzymanie jego stanu w tym samym pliku dawałoby
raw = base64.b64decode(header.split(" ", 1)[1]).decode("utf-8") zakleszczenie: nie da się naprawić, bo nie da się wejść.
user, _, password = raw.partition(":") """
except (binascii.Error, UnicodeDecodeError, IndexError): return accounts().get(login)
return None
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: if spec is not None:
return (Principal(user, _admin_permissions(), True) return Principal(login, _admin_permissions(), True) if _secret_matches(spec, password) else None
if _secret_matches(spec, password) else None)
from app import accounts as store from app import accounts as store
managed = store.secret_of(user) try:
if managed is None: 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 return None
if not _secret_matches(managed, password): if zapisany is None or not _secret_matches(zapisany, password):
return None 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: def issue_session(login: str) -> str:
"""Sam LOGIN — do dziennika audytowego (PRE-17) i zgodności wstecz.""" """Zawartość ciasteczka dla właśnie zalogowanego konta.
who = principal(header)
return who.login if who else None 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: def client_ip(request: Request) -> str:
@@ -270,6 +327,22 @@ def audit_records(request: Request, count: int) -> None:
pass 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: def _audit(request: Request, user: str, status: int, ms: float) -> None:
records = getattr(request.state, "audit_records", None) records = getattr(request.state, "audit_records", None)
audit_log.info( audit_log.info(
@@ -280,8 +353,20 @@ def _audit(request: Request, user: str, status: int, ms: float) -> None:
def install(app) -> 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() _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(): if not auth_enabled():
log.warning( log.warning(
"UWAGA: brak kont (APP_USERS / APP_PASSWORD) — aplikacja jest OTWARTA " "UWAGA: brak kont (APP_USERS / APP_PASSWORD) — aplikacja jest OTWARTA "
@@ -311,16 +396,27 @@ def install(app) -> None:
from app import features from app import features
user, who = "", None user, who, odswiez = "", None, None
if auth_enabled(): if auth_enabled():
who = principal(request.headers.get("authorization")) who = principal(request)
if who is None: if who is None:
_audit(request, "-", 401, (time.monotonic() - started) * 1000) _audit(request, "-", 303, (time.monotonic() - started) * 1000)
return HTMLResponse( # Przekierowanie na ekran logowania, nie 401: 401 z Basic otwierało
"<h1>401 — wymagane logowanie</h1>", status_code=401, # okienko systemowe, którego nie da się ani opisać, ani z niego wyjść.
headers={"WWW-Authenticate": 'Basic realm="astrololo"'}, # 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 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: else:
# Bez kont ochrona jest wyłączona (dev), więc widać wszystko — inaczej # 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. # 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) _audit(request, user, 404, (time.monotonic() - started) * 1000)
return HTMLResponse("<h1>404 — nie znaleziono</h1>", status_code=404) return HTMLResponse("<h1>404 — nie znaleziono</h1>", status_code=404)
response = await call_next(request) 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) _audit(request, user, response.status_code, (time.monotonic() - started) * 1000)
return response return response
+183
View File
@@ -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": "/"}
+14 -2
View File
@@ -271,5 +271,17 @@ tr.row-held td { background: rgba(184, 134, 11, .07); }
rozpychać. */ rozpychać. */
.whoami { color: var(--muted); font-size: .85rem; align-self: center; .whoami { color: var(--muted); font-size: .85rem; align-self: center;
margin-left: 1rem; padding-left: 1rem; border-left: 1px solid var(--line); } margin-left: 1rem; padding-left: 1rem; border-left: 1px solid var(--line); }
nav a.wyloguj { color: var(--muted); } /* Wylogowanie idzie POST-em, więc jest przyciskiem — ale ma wyglądać jak
nav a.wyloguj:hover { color: var(--fg, inherit); } 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; }
@@ -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;
});
});
});
})();
@@ -53,6 +53,10 @@
</div> </div>
<div class="actions"> <div class="actions">
<button type="submit">Zapisz</button> <button type="submit">Zapisz</button>
<button type="submit" formaction="/accounts/sesje" class="ghost"
title="Zamyka otwarte sesje tego konta na wszystkich urządzeniach. Hasło zostaje bez zmian.">
Zamknij sesje
</button>
<button type="submit" formaction="/accounts/delete" class="danger" <button type="submit" formaction="/accounts/delete" class="danger"
onclick="return confirm('Skasować konto {{ login }}? Tej operacji nie da się cofnąć.')"> onclick="return confirm('Skasować konto {{ login }}? Tej operacji nie da się cofnąć.')">
Skasuj konto Skasuj konto
@@ -13,9 +13,6 @@
{# Powiększanie kosmogramu (PRE-25) — globalnie, bo koło pojawi się też na {# Powiększanie kosmogramu (PRE-25) — globalnie, bo koło pojawi się też na
zakładce „Skompiluj"; skrypt sam sprawdza, czy jest co powiększać. #} zakładce „Skompiluj"; skrypt sam sprawdza, czy jest co powiększać. #}
<script defer src="{{ static('wheelzoom.js') }}"></script> <script defer src="{{ static('wheelzoom.js') }}"></script>
{# Wylogowanie z Basic wymaga kroku po stronie przeglądarki — patrz komentarz
w wyloguj.js. Bez JavaScriptu link nadal działa, tylko słabiej. #}
<script defer src="{{ static('wyloguj.js') }}"></script>
<script defer src="{{ static('wheeltip.js') }}"></script> <script defer src="{{ static('wheeltip.js') }}"></script>
</head> </head>
<body> <body>
@@ -39,7 +36,11 @@
wylogowuje. Bez włączonego logowania nie ma z czego wychodzić. #} wylogowuje. Bez włączonego logowania nie ma z czego wychodzić. #}
{% if auth_on() %} {% if auth_on() %}
<span class="whoami" title="Zalogowano jako">{{ current_user(request) }}</span> <span class="whoami" title="Zalogowano jako">{{ current_user(request) }}</span>
<a href="/wyloguj" class="wyloguj" data-wyloguj>Wyloguj</a> {# POST, nie odsyłacz: pod adresem GET wystarczyłby obrazek na obcej
stronie, żeby wylogować kogoś bez jego wiedzy. #}
<form method="post" action="/wyloguj" class="wyloguj-form">
<button type="submit" class="wyloguj">Wyloguj</button>
</form>
{% endif %} {% endif %}
</nav> </nav>
</header> </header>
@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>astrololo · logowanie</title>
<link rel="stylesheet" href="{{ static('styles.css') }}">
</head>
<body>
<main class="login-main">
<header class="topbar"><h1>astrololo</h1></header>
{% if request.query_params.get('wylogowano') %}
<p class="done-note">Wylogowano.</p>
{% endif %}
{% if blad %}<p class="house-warning">{{ blad }}</p>{% endif %}
<form method="post" action="/logowanie" class="account-card login-card">
{# Adres powrotu jedzie w ukrytym polu, ale i tak przechodzi przez sito
po stronie serwera — pole formularza jest wejściem od użytkownika. #}
<input type="hidden" name="dokad" value="{{ dokad }}">
<label>Login
<input type="text" name="login" value="{{ login or '' }}" required
autocomplete="username" autofocus></label>
<label>Hasło
<input type="password" name="haslo" required autocomplete="current-password"></label>
<div class="actions"><button type="submit">Zaloguj</button></div>
</form>
<p class="muted small login-note">
Nie masz konta albo nie pamiętasz hasła? Zgłoś się do osoby, która zakładała
Ci dostęp — kont nie da się odzyskać samodzielnie.
</p>
</main>
</body>
</html>
@@ -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ć 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). treści baz (byłby wtedy kolejnym nośnikiem wycieku).
""" """
import base64
import logging import logging
import pytest import pytest
@@ -13,9 +12,13 @@ import pytest
from app import security from app import security
def _basic(user: str, password: str) -> dict: def _kto(user: str, password: str) -> str | None:
raw = base64.b64encode(f"{user}:{password}".encode()).decode() """Login, gdy poświadczenia pasują; None przy odmowie.
return {"Authorization": f"Basic {raw}"}
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) @pytest.fixture(autouse=True)
@@ -57,29 +60,29 @@ def test_named_accounts_are_parsed(monkeypatch):
def test_login_with_hashed_password(monkeypatch): def test_login_with_hashed_password(monkeypatch):
monkeypatch.setenv("APP_USERS", f"alicja:{security.hash_password('tajne')}") monkeypatch.setenv("APP_USERS", f"alicja:{security.hash_password('tajne')}")
assert security.authenticate(_basic("alicja", "tajne")["Authorization"]) == "alicja" assert _kto("alicja", "tajne") == "alicja"
assert security.authenticate(_basic("alicja", "złe")["Authorization"]) is None assert _kto("alicja", "złe") is None
def test_unknown_user_is_denied(monkeypatch): def test_unknown_user_is_denied(monkeypatch):
monkeypatch.setenv("APP_USERS", "alicja:tajne") 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): def test_revoking_one_account_leaves_others_untouched(monkeypatch):
"""Sedno PRE-17: odcinamy jedną osobę BEZ zmiany hasła pozostałym.""" """Sedno PRE-17: odcinamy jedną osobę BEZ zmiany hasła pozostałym."""
monkeypatch.setenv("APP_USERS", "alicja:tajne,bartek:inne") 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 monkeypatch.setenv("APP_USERS", "alicja:tajne") # usunięto wpis Bartka
assert security.authenticate(_basic("bartek", "inne")["Authorization"]) is None assert _kto("bartek", "inne") is None
assert security.authenticate(_basic("alicja", "tajne")["Authorization"]) == "alicja" assert _kto("alicja", "tajne") == "alicja"
def test_shared_password_still_works_alone(monkeypatch): def test_shared_password_still_works_alone(monkeypatch):
"""Zgodność wstecz — dotychczasowe wdrożenie nie może paść po aktualizacji.""" """Zgodność wstecz — dotychczasowe wdrożenie nie może paść po aktualizacji."""
monkeypatch.setenv("APP_USER", "astrololo") monkeypatch.setenv("APP_USER", "astrololo")
monkeypatch.setenv("APP_PASSWORD", "wspolne") 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): 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_USERS", "alicja:tajne")
monkeypatch.setenv("APP_USER", "astrololo") monkeypatch.setenv("APP_USER", "astrololo")
monkeypatch.setenv("APP_PASSWORD", "wspolne") 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): def test_no_accounts_means_auth_disabled(monkeypatch):
@@ -97,28 +100,82 @@ def test_no_accounts_means_auth_disabled(monkeypatch):
# ── dziennik audytowy ── # ── 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 starlette.testclient import TestClient
from app.main import app 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): def test_audit_logs_who_and_how_many(monkeypatch, caplog):
monkeypatch.setenv("APP_USERS", "alicja:tajne") monkeypatch.setenv("APP_USERS", "alicja:tajne")
with caplog.at_level(logging.INFO, logger="astrololo.audit"): c = _client(monkeypatch)
_client().get("/synastry", headers=_basic("alicja", "tajne")) with _dziennik(caplog) as d:
entry = "\n".join(r.getMessage() for r in caplog.records) c.get("/synastry", headers=_sesja("alicja"))
entry = d.tekst
assert "user=alicja" in entry and "path=/synastry" in entry and "status=200" in entry 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") monkeypatch.setenv("APP_USERS", "alicja:tajne")
with caplog.at_level(logging.INFO, logger="astrololo.audit"): c = _client(monkeypatch)
_client().get("/synastry", headers=_basic("alicja", "złe")) with _dziennik(caplog) as d:
entry = "\n".join(r.getMessage() for r in caplog.records) c.get("/synastry")
assert "status=401" in entry and "user=-" in entry 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): def test_audit_logger_actually_emits(monkeypatch):
@@ -28,7 +28,10 @@ def env(tmp_path, monkeypatch):
monkeypatch.setenv("APP_USER", "szef") monkeypatch.setenv("APP_USER", "szef")
monkeypatch.setenv("APP_PASSWORD", "tajne-szefa") monkeypatch.setenv("APP_PASSWORD", "tajne-szefa")
monkeypatch.delenv("APP_USERS", raising=False) 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 return tmp_path
@@ -41,11 +44,16 @@ def _client(monkeypatch, chart=None):
return TestClient(app) return TestClient(app)
def _auth(user, password): def _auth(user, password=""):
import base64 """Nagłówek z WAŻNĄ SESJĄ dla konta — odpowiednik bycia zalogowanym.
raw = base64.b64encode(f"{user}:{password}".encode()).decode() Po przejściu z Basic na sesje (LOG-34) „zalogowany" nie znaczy już „ma
return {"Authorization": f"Basic {raw}"} 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(): def _chart():
@@ -130,7 +138,7 @@ def test_empty_password_on_update_keeps_the_old_one(env):
# ── logowanie i rozpoznanie konta ──────────────────────────────────────── # ── logowanie i rozpoznanie konta ────────────────────────────────────────
def test_environment_account_is_the_administrator(env): 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 who and who.is_admin
assert features.ADMIN in who.permissions assert features.ADMIN in who.permissions
assert features.GRANTABLE <= who.permissions, "administrator ma widzieć wszystko" 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): def test_managed_account_gets_exactly_its_own_permissions(env):
store.create("ala", "hasło-ali", ["chart", "ai"]) 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 and not who.is_admin
assert who.permissions == frozenset({"chart", "ai"}) 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ć — """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ć.""" inaczej dałoby się odebrać uprawnienia jedynemu, kto może je nadawać."""
store.create("szef", "podszywam-się", []) 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 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): def test_wrong_password_is_refused_the_same_way_as_unknown_login(env):
store.create("ala", "hasło-ali", ["chart"]) store.create("ala", "hasło-ali", ["chart"])
assert security.principal(_auth("ala", "złe")["Authorization"]) is None assert security.verify("ala", "złe") is None
assert security.principal(_auth("nie-ma-takiego", "cokolwiek")["Authorization"]) is None assert security.verify("nie-ma-takiego", "cokolwiek") is None
# ── niewidzialność funkcji ─────────────────────────────────────────────── # ── niewidzialność funkcji ───────────────────────────────────────────────
@@ -294,9 +302,10 @@ def test_administrator_creates_an_account_and_it_works_immediately(env, monkeypa
data={"login": "nowa", "granted": ["chart"]}) data={"login": "nowa", "granted": ["chart"]})
assert c.get("/significators", headers=her).status_code == 404 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"}) 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): 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" "uszkodzony plik został nadpisany — konta by zniknęły"
# ── wylogowanie (LOG-32) ───────────────────────────────────────────────── # ── logowanie i wylogowanie (LOG-34) ─────────────────────────────────────
# HTTP Basic nie ma sesji: przeglądarka dosyła zapamiętane dane przy KAŻDYM # Sesja zastąpiła HTTP Basic. Basic nie miał wylogowania, bo przeglądarka
# żądaniu, więc serwer nie ma czego zapomnieć. Jedyne, co działa, to doprowadzić # dosyłała hasło sama przy każdym żądaniu; teraz to serwer decyduje, czy dana
# do porzucenia tych danych przez przeglądarkę — stąd 401 z tej trasy jest # przeglądarka jest w środku, i może to cofnąć.
# WYNIKIEM, nie awarią.
def test_logout_always_answers_401_even_with_valid_credentials(env, monkeypatch): def test_logging_in_hands_out_a_session_and_takes_you_where_you_were_going(env, monkeypatch):
"""Sedno: 200 dla zalogowanej osoby oznaczałoby, że nie da się wyjść.""" store.create("ala", "hasło-ali", ["chart"])
c = _client(monkeypatch) c = _client(monkeypatch)
r = c.get("/wyloguj", headers=_auth("szef", "tajne-szefa")) r = c.post("/logowanie", follow_redirects=False,
assert r.status_code == 401 data={"login": "ala", "haslo": "hasło-ali", "dokad": "/"})
assert r.headers.get("WWW-Authenticate", "").lower().startswith("basic"), \ assert r.status_code == 303 and r.headers["location"] == "/"
"bez tego nagłówka przeglądarka nie zapyta ponownie o hasło" from app import session
assert session.COOKIE in r.cookies, "nie wydano ciasteczka sesji"
def test_logout_works_without_being_logged_in(env, monkeypatch): def test_a_bad_password_says_the_same_thing_as_an_unknown_login(env, monkeypatch):
"""Trasa stoi poza bramką, więc wyjście działa też z nieważnymi danymi — """Rozróżnienie mówiłoby, które konta istnieją."""
inaczej wylogowanie wymagałoby bycia zalogowanym, co jest błędnym kołem.""" store.create("ala", "hasło-ali", ["chart"])
c = _client(monkeypatch) 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): def test_logging_out_drops_the_session(env, monkeypatch):
"""Oddana z pamięci podręcznej nie dotarłaby do serwera, więc okienko """Sedno całej zmiany: po wylogowaniu przeglądarka nie ma już czego dosyłać."""
logowania w ogóle by się nie pojawiło.""" store.create("ala", "x", ["chart"])
c = _client(monkeypatch) c = _client(monkeypatch)
cc = c.get("/wyloguj").headers.get("Cache-Control", "") assert c.get("/", headers=_auth("ala"), follow_redirects=False).status_code == 200
assert "no-store" in cc and "no-cache" in cc
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): def test_logging_out_is_a_post_not_a_link(env, monkeypatch):
"""Nie wolno obiecywać więcej, niż Basic potrafi: pewne wyjście na wspólnym """Pod adresem GET wystarczyłby obrazek na obcej stronie, żeby wylogować
komputerze to zamknięcie przeglądarki i strona ma to mówić wprost.""" kogoś bez jego wiedzy. Uciążliwość, nie włamanie — ale darmowa do uniknięcia."""
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", [])
c = _client(monkeypatch) 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): 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.""" bezużyteczny — na wspólnym komputerze nie wiadomo, kogo się wylogowuje."""
store.create("ala", "x", ["chart", "significators"]) store.create("ala", "x", ["chart", "significators"])
c = _client(monkeypatch) c = _client(monkeypatch)
for kto, haslo in (("szef", "tajne-szefa"), ("ala", "x")): for kto in ("szef", "ala"):
html = c.get("/", headers=_auth(kto, haslo)).text html = c.get("/", headers=_auth(kto)).text
assert 'href="/wyloguj"' in html, f"{kto}: brak wyjścia" assert 'action="/wyloguj"' in html, f"{kto}: brak wyjścia"
assert kto in html, f"{kto}: nie widać, kto jest zalogowany" 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_PASSWORD", raising=False)
monkeypatch.delenv("APP_USERS", raising=False) monkeypatch.delenv("APP_USERS", raising=False)
c = _client(monkeypatch) 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): def test_every_account_can_leave_regardless_of_permissions(env, monkeypatch):
"""Sam 401 zostawiłby stare dane w pamięci przeglądarki: po anulowaniu """Konto bez ani jednego ekranu też ma prawo wyjść."""
okienka wystarczyłoby wejść na dowolny adres, żeby wrócić do środka. store.create("bez", "x", [])
Skrypt dokłada żądanie z celowo błędnymi danymi, które ten wpis nadpisuje.""" c = _client(monkeypatch)
import pathlib 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" def test_the_login_screen_cannot_be_turned_into_a_redirector(env, monkeypatch):
/ "wyloguj.js").read_text(encoding="utf-8") """`?dokad=https://obcy.pl` zamieniłby nasz ekran logowania w narzędzie do
assert "Authorization" in js and "btoa" in js odsyłania ludzi gdzie indziej — droga do wyłudzenia hasła, bo odsyłacz
assert "Date.now()" in js, "bez zmiennej części dane mogłyby się powtórzyć" 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
+12 -4
View File
@@ -21,14 +21,22 @@ def env(tmp_path, monkeypatch):
monkeypatch.setenv("APP_PASSWORD", "tajne-szefa") monkeypatch.setenv("APP_PASSWORD", "tajne-szefa")
monkeypatch.delenv("APP_USERS", raising=False) monkeypatch.delenv("APP_USERS", raising=False)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0") 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 return tmp_path
def _auth(user, password): def _auth(user, password=""):
import base64 """Nagłówek z WAŻNĄ SESJĄ dla konta — odpowiednik bycia zalogowanym.
return {"Authorization": "Basic " + base64.b64encode( Po przejściu z Basic na sesje (LOG-34) zalogowany" nie znaczy już „ma
f"{user}:{password}".encode()).decode()} 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 = [ REGISTRY = [
+78 -26
View File
@@ -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 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ą, mógł je wypompować przez `/significators` czy generator promptu. Testy pilnują,
że brama faktycznie zamyka, a nie tylko wygląda na zamkniętą. ż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 import pytest
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
@@ -16,9 +18,11 @@ from app import security
USER, PASSWORD = "astrololo", "haslo-testowe" USER, PASSWORD = "astrololo", "haslo-testowe"
def _basic(user: str, password: str) -> dict[str, str]: def _sesja(user: str = USER) -> dict[str, str]:
raw = base64.b64encode(f"{user}:{password}".encode()).decode() """Nagłówek z ważną sesją — odpowiednik bycia zalogowanym."""
return {"Authorization": f"Basic {raw}"} from app import session
return {"Cookie": f"{session.COOKIE}={security.issue_session(user)}"}
def _app() -> FastAPI: def _app() -> FastAPI:
@@ -47,48 +51,93 @@ def _reset_rate_limit():
def guarded(monkeypatch): def guarded(monkeypatch):
monkeypatch.setenv("APP_PASSWORD", PASSWORD) monkeypatch.setenv("APP_PASSWORD", PASSWORD)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "120") 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 # ------------------------------------------------------------------ logowanie
def test_blocks_anonymous_access(guarded): def test_blocks_anonymous_access(guarded):
r = guarded.get("/significators") r = guarded.get("/significators")
assert r.status_code == 401 assert r.status_code == 303
assert "treść z bazy" not in r.text assert "treść z bazy" not in r.text
def test_challenges_with_basic_realm(guarded): def test_sends_people_to_the_login_screen_not_a_system_dialog(guarded):
"""Bez nagłówka WWW-Authenticate przeglądarka nie pokaże okna logowania.""" """Basic odpowiadał 401 z WWW-Authenticate, co otwierało okienko systemowe:
assert "Basic" in guarded.get("/significators").headers.get("WWW-Authenticate", "") 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): def test_remembers_where_someone_was_heading(guarded):
assert guarded.get("/significators", headers=_basic(USER, "zle")).status_code == 401 """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): def test_no_session_no_content(guarded):
assert guarded.get("/significators", headers=_basic("obcy", PASSWORD)).status_code == 401 """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): def test_allows_a_valid_session(guarded):
for bad in ("Basic !!!niebase64!!!", "Bearer cokolwiek", "", "Basic"): r = guarded.get("/significators", headers=_sesja())
assert guarded.get("/significators", headers={"Authorization": bad}).status_code == 401
def test_allows_correct_credentials(guarded):
r = guarded.get("/significators", headers=_basic(USER, PASSWORD))
assert r.status_code == 200 assert r.status_code == 200
assert "treść z bazy" in r.text 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 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): def test_health_stays_public(guarded):
assert guarded.get("/health").status_code == 200 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): 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("APP_PASSWORD", raising=False)
monkeypatch.delenv("SESSION_SECRET", raising=False)
assert not security.auth_enabled() assert not security.auth_enabled()
assert TestClient(_app()).get("/significators").status_code == 200 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.""" """Masowe odpytywanie to droga eksfiltracji nawet po zalogowaniu."""
monkeypatch.setenv("APP_PASSWORD", PASSWORD) monkeypatch.setenv("APP_PASSWORD", PASSWORD)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "5") monkeypatch.setenv("RATE_LIMIT_PER_MIN", "5")
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
client = TestClient(_app()) client = TestClient(_app())
auth = _basic(USER, PASSWORD) auth = _sesja()
codes = [client.get("/significators", headers=auth).status_code for _ in range(8)] codes = [client.get("/significators", headers=auth).status_code for _ in range(8)]
assert codes[:5] == [200] * 5 assert codes[:5] == [200] * 5
assert 429 in codes[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): def test_rate_limited_response_has_retry_after(monkeypatch):
monkeypatch.setenv("APP_PASSWORD", PASSWORD) monkeypatch.setenv("APP_PASSWORD", PASSWORD)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "1") monkeypatch.setenv("RATE_LIMIT_PER_MIN", "1")
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
client = TestClient(_app()) client = TestClient(_app())
auth = _basic(USER, PASSWORD) auth = _sesja()
client.get("/significators", headers=auth) client.get("/significators", headers=auth)
r = client.get("/significators", headers=auth) r = client.get("/significators", headers=auth)
assert r.status_code == 429 and r.headers.get("Retry-After") == "60" 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.""" i sondowanie API jest darmowe."""
monkeypatch.setenv("APP_PASSWORD", PASSWORD) monkeypatch.setenv("APP_PASSWORD", PASSWORD)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "3") 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)] codes = [client.get("/significators").status_code for _ in range(6)]
assert 429 in codes assert 429 in codes
+143
View File
@@ -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