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:
Binary file not shown.
@@ -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"))
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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": "/"}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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 aż 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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user