feat(astroklient-demo): sesje logowania zamiast HTTP Basic (LOG-34)
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m21s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m30s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m25s
Testy / Testy astroklienta-demo (push) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 5s
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 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m25s
Testy / Testy astroklienta-demo (pull_request) Successful in 9m26s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 5s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 4s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m21s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m30s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m25s
Testy / Testy astroklienta-demo (push) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 5s
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 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m25s
Testy / Testy astroklienta-demo (pull_request) Successful in 9m26s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 5s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 4s
Demo idzie szeroko i do różnych osób, często na cudzych komputerach — więc
wyjście z aplikacji jest tu potrzebne bardziej niż w pełnej wersji, a Basic go
nie miał: przeglądarka zapamiętuje hasło i dosyła je sama przy każdym żądaniu.
Pierwszy klient zostawiał otwartą sesję drugiemu.
Ta sama konstrukcja co w pełnej aplikacji: własny ekran logowania, podpisane
ciasteczko (HMAC-SHA256), HttpOnly + SameSite=Strict, wylogowanie POST-em, kres
bezczynności i twardy, sito na adres powrotu, zdarzenia w dzienniku bez haseł.
Moduł session.py skopiowany, tak samo jak link_crypto — usługi są osobnymi
obrazami i nie importują się nawzajem.
DWIE RÓŻNICE WOBEC PEŁNEJ WERSJI, obie wynikające z tego, że demo nie ma
własnego wolumenu:
* nazwa ciasteczka jest inna. Gdyby obie aplikacje stanęły kiedyś pod jedną
domeną, ciasteczka o tej samej nazwie nadpisywałyby się i człowiek wypadałby
z jednej, logując się do drugiej.
* nie ma licznika pokolenia sesji, bo nie ma go gdzie zapisać. Zdalne
unieważnienie robi się przez DEMO_USERS: usunięcie konta albo zmiana hasła
NATYCHMIAST ubija jego otwarte sesje, bo odcisk poświadczenia w ciasteczku
przestaje pasować. Osobny test tego pilnuje. Wylogowanie i tak działa
natychmiast, bo polega na skasowaniu ciasteczka.
Klucz podpisu jest WŁASNY, nie ten z pełnej aplikacji: demo i produkcja nie mają
powodu uznawać nawzajem swoich sesji, a wspólny klucz znaczyłby, że sesja z demo
bywa ważna tam, gdzie nie powinna.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,7 +27,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from app import link_crypto, security
|
from app import link_crypto, security, session
|
||||||
from app.clients.logic_client import LogicClient
|
from app.clients.logic_client import LogicClient
|
||||||
|
|
||||||
# Automatyczna dokumentacja WYŁĄCZONA — tak samo jak w pełnej aplikacji. /docs
|
# Automatyczna dokumentacja WYŁĄCZONA — tak samo jak w pełnej aplikacji. /docs
|
||||||
@@ -37,6 +37,8 @@ security.install(app)
|
|||||||
|
|
||||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||||
templates = Jinja2Templates(directory="app/templates")
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
|
templates.env.globals["current_user"] = lambda request: getattr(request.state, "user", "") or ""
|
||||||
|
templates.env.globals["auth_on"] = security.auth_enabled
|
||||||
|
|
||||||
def _logic(request: Request) -> LogicClient:
|
def _logic(request: Request) -> LogicClient:
|
||||||
"""Klient związany z pulą ZALOGOWANEGO konta.
|
"""Klient związany z pulą ZALOGOWANEGO konta.
|
||||||
@@ -150,6 +152,59 @@ def _wroc(error: str = "", done: str = "") -> RedirectResponse:
|
|||||||
return RedirectResponse(f"/{'?' + q if q else ''}", status_code=303)
|
return RedirectResponse(f"/{'?' + q if q else ''}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Logowanie i wylogowanie (LOG-34) ----------------
|
||||||
|
# Ta sama konstrukcja co w pełnej aplikacji. Różnica jest jedna i wynika z tego,
|
||||||
|
# że demo nie ma własnego wolumenu: nie ma licznika pokolenia sesji, więc
|
||||||
|
# „wyrzuć zewsząd" robi się zmianą hasła w DEMO_USERS. Wylogowanie i tak działa
|
||||||
|
# natychmiast, bo polega na skasowaniu ciasteczka.
|
||||||
|
|
||||||
|
def _bezpieczne_dokad(dokad: str) -> str:
|
||||||
|
"""Adres powrotu przepuszczony przez sito — bez tego `?dokad=https://obcy.pl`
|
||||||
|
zamieniłby ekran logowania w narzędzie do wyłudzania haseł."""
|
||||||
|
dokad = (dokad or "").strip()
|
||||||
|
if not dokad.startswith("/") or dokad.startswith("//") or "\\" in dokad:
|
||||||
|
return "/"
|
||||||
|
return dokad.split("?", 1)[0].split("#", 1)[0] or "/"
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/logowanie", response_class=HTMLResponse)
|
||||||
|
def logowanie_form(request: Request, dokad: str = "/", blad: str = ""):
|
||||||
|
if security.auth_enabled() and security.principal(request):
|
||||||
|
return RedirectResponse(_bezpieczne_dokad(dokad), status_code=303)
|
||||||
|
return templates.TemplateResponse(request, "logowanie.html",
|
||||||
|
{"dokad": _bezpieczne_dokad(dokad), "blad": blad})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/logowanie")
|
||||||
|
def logowanie(request: Request, login: str = Form(""), haslo: str = Form(""),
|
||||||
|
dokad: str = Form("/")):
|
||||||
|
cel = _bezpieczne_dokad(dokad)
|
||||||
|
kto = security.verify(login.strip(), haslo)
|
||||||
|
if kto is None:
|
||||||
|
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, kto, udane=True)
|
||||||
|
odpowiedz = RedirectResponse(cel, status_code=303)
|
||||||
|
odpowiedz.set_cookie(session.COOKIE, security.issue_session(kto),
|
||||||
|
max_age=session.max_age(),
|
||||||
|
**session.cookie_params(secure=security.cookies_secure()))
|
||||||
|
return odpowiedz
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/wyloguj")
|
||||||
|
def wyloguj(request: Request):
|
||||||
|
"""POST, nie odsyłacz: pod adresem GET wystarczyłby obrazek na obcej stronie,
|
||||||
|
żeby wylogować kogoś bez jego wiedzy."""
|
||||||
|
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")
|
||||||
def health() -> dict:
|
def health() -> dict:
|
||||||
return {"status": "ok", "layer": "astroklient-demo"}
|
return {"status": "ok", "layer": "astroklient-demo"}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import time
|
|||||||
from collections import deque
|
from collections import deque
|
||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||||
|
|
||||||
log = logging.getLogger("astroklient-demo.security")
|
log = logging.getLogger("astroklient-demo.security")
|
||||||
audit_log = logging.getLogger("astroklient-demo.audit")
|
audit_log = logging.getLogger("astroklient-demo.audit")
|
||||||
@@ -32,7 +32,9 @@ audit_log = logging.getLogger("astroklient-demo.audit")
|
|||||||
_SCRYPT_N, _SCRYPT_R, _SCRYPT_P, _SCRYPT_LEN = 2**14, 8, 1, 32
|
_SCRYPT_N, _SCRYPT_R, _SCRYPT_P, _SCRYPT_LEN = 2**14, 8, 1, 32
|
||||||
_HASH_PREFIX = "scrypt$"
|
_HASH_PREFIX = "scrypt$"
|
||||||
|
|
||||||
PUBLIC_PATHS = frozenset({"/health"})
|
# /logowanie i /wyloguj poza bramką: inaczej żeby się zalogować, trzeba by być
|
||||||
|
# zalogowanym, a wyjście nie działałoby po wygaśnięciu sesji.
|
||||||
|
PUBLIC_PATHS = frozenset({"/health", "/logowanie", "/wyloguj"})
|
||||||
PUBLIC_PREFIXES = ("/static/",)
|
PUBLIC_PREFIXES = ("/static/",)
|
||||||
|
|
||||||
_hits: dict[str, deque[float]] = {}
|
_hits: dict[str, deque[float]] = {}
|
||||||
@@ -92,20 +94,41 @@ def _secret_matches(spec: str, password: str) -> bool:
|
|||||||
return hmac.compare_digest(spec.encode("utf-8"), password.encode("utf-8"))
|
return hmac.compare_digest(spec.encode("utf-8"), password.encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
def authenticate(header: str | None) -> str | None:
|
def verify(login: str, password: str) -> str | None:
|
||||||
if not header or not header.lower().startswith("basic "):
|
"""Sprawdza login i hasło; zwraca login albo None.
|
||||||
return None
|
|
||||||
try:
|
Nieznany login i złe hasło traktujemy tak samo — odmowa nie mówi, które
|
||||||
raw = base64.b64decode(header.split(" ", 1)[1]).decode("utf-8")
|
konto istnieje, a przy demo rozdawanym szeroko to realna informacja."""
|
||||||
user, _, password = raw.partition(":")
|
spec = accounts().get(login)
|
||||||
except (binascii.Error, UnicodeDecodeError, IndexError):
|
|
||||||
return None
|
|
||||||
spec = accounts().get(user)
|
|
||||||
if spec is None:
|
if spec is None:
|
||||||
return None
|
return None
|
||||||
# Nieznany login i złe hasło traktujemy tak samo — odmowa nie mówi, które
|
return login if _secret_matches(spec, password) else None
|
||||||
# konto istnieje, a przy demo rozdawanym szeroko to realna informacja.
|
|
||||||
return user if _secret_matches(spec, password) else None
|
|
||||||
|
def issue_session(login: str) -> str:
|
||||||
|
"""Zawartość ciasteczka dla właśnie zalogowanego konta.
|
||||||
|
|
||||||
|
W ciasteczku ląduje ODCISK poświadczenia, więc zmiana hasła albo usunięcie
|
||||||
|
konta z DEMO_USERS unieważnia jego sesje samo z siebie. To jest tu jedyny
|
||||||
|
sposób zdalnego unieważnienia: demo nie ma gdzie trwale zapisywać stanu,
|
||||||
|
bo nie ma własnego wolumenu — a stan sesji na udziale z bazami klientów
|
||||||
|
byłby pomieszaniem dwóch zupełnie różnych rzeczy."""
|
||||||
|
from app import session
|
||||||
|
|
||||||
|
return session.issue(login, session.fingerprint(accounts().get(login) or ""), 0)
|
||||||
|
|
||||||
|
|
||||||
|
def principal(request: Request) -> str | None:
|
||||||
|
"""Ciasteczko → login zalogowanej osoby albo None."""
|
||||||
|
from app import session
|
||||||
|
|
||||||
|
dane = session.read(request.cookies.get(session.COOKIE))
|
||||||
|
if dane is None:
|
||||||
|
return None
|
||||||
|
spec = accounts().get(dane["login"])
|
||||||
|
if spec is None or session.fingerprint(spec) != dane["odcisk"]:
|
||||||
|
return None
|
||||||
|
return dane["login"]
|
||||||
|
|
||||||
|
|
||||||
def client_ip(request: Request) -> str:
|
def client_ip(request: Request) -> str:
|
||||||
@@ -159,8 +182,39 @@ def audit_records(request: Request, count: int) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def cookies_secure() -> bool:
|
||||||
|
"""Ciasteczko tylko dla https. Wyłączane wyłącznie w pracy lokalnej po http —
|
||||||
|
inaczej przeglądarka po prostu go nie zapamięta i logowanie „nie działa"
|
||||||
|
bez żadnego komunikatu."""
|
||||||
|
return os.getenv("COOKIES_INSECURE", "").strip().lower() not in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def audit_login(request: Request, login: str, udane: bool) -> None:
|
||||||
|
"""Zdarzenie logowania. Nieudane próby są ważniejsze od udanych: pojedyncza
|
||||||
|
nic nie znaczy, seria pod jednym adresem to ślad zgadywania haseł. Login tak,
|
||||||
|
hasło NIGDY."""
|
||||||
|
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 install(app) -> None:
|
def install(app) -> None:
|
||||||
|
from app import session
|
||||||
|
|
||||||
_setup_audit_logging()
|
_setup_audit_logging()
|
||||||
|
|
||||||
|
# Fail-closed: konta bez klucza podpisu to sesje nie do odróżnienia od
|
||||||
|
# podrobionych. Lepiej, żeby usługa nie wstała.
|
||||||
|
if auth_enabled() and not session.available():
|
||||||
|
raise session.SessionError(
|
||||||
|
"Konta demo są skonfigurowane, ale brakuje SESSION_SECRET — bez klucza "
|
||||||
|
"podpisu logowanie nie miałoby czym się bronić. openssl rand -hex 32")
|
||||||
|
|
||||||
if not auth_enabled():
|
if not auth_enabled():
|
||||||
log.warning(
|
log.warning(
|
||||||
"UWAGA: brak kont (DEMO_USERS / DEMO_PASSWORD) — astroklient-demo stoi OTWARTY. "
|
"UWAGA: brak kont (DEMO_USERS / DEMO_PASSWORD) — astroklient-demo stoi OTWARTY. "
|
||||||
@@ -178,16 +232,27 @@ def install(app) -> None:
|
|||||||
return JSONResponse({"detail": "Zbyt wiele żądań — spróbuj za chwilę."},
|
return JSONResponse({"detail": "Zbyt wiele żądań — spróbuj za chwilę."},
|
||||||
status_code=429, headers={"Retry-After": "60"})
|
status_code=429, headers={"Retry-After": "60"})
|
||||||
|
|
||||||
user = ""
|
user, odswiez = "", None
|
||||||
if auth_enabled():
|
if auth_enabled():
|
||||||
user = authenticate(request.headers.get("authorization")) or ""
|
user = principal(request) or ""
|
||||||
if not user:
|
if not user:
|
||||||
return HTMLResponse(
|
from urllib.parse import quote
|
||||||
"<h1>401 — wymagane logowanie</h1>", status_code=401,
|
|
||||||
headers={"WWW-Authenticate": 'Basic realm="astroklient-demo"'})
|
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)
|
||||||
|
from app import session as _s
|
||||||
|
|
||||||
|
dane = _s.read(request.cookies.get(_s.COOKIE))
|
||||||
|
odswiez = _s.refresh(dane) if dane else None
|
||||||
request.state.user = user
|
request.state.user = user
|
||||||
|
|
||||||
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()))
|
||||||
records = getattr(request.state, "audit_records", None)
|
records = getattr(request.state, "audit_records", None)
|
||||||
audit_log.info(
|
audit_log.info(
|
||||||
"AUDYT user=%s ip=%s method=%s path=%s status=%s records=%s ms=%.0f",
|
"AUDYT user=%s ip=%s method=%s path=%s status=%s records=%s ms=%.0f",
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"""Sesje logowania — podpisane ciasteczko (LOG-34).
|
||||||
|
|
||||||
|
Kopia modułu z warstwy prezentacji, tak samo jak link_crypto: usługi są osobnymi
|
||||||
|
obrazami i nie importują się nawzajem. Różni je WYŁĄCZNIE nazwa ciasteczka.
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Własna nazwa, nie ta z pełnej aplikacji: gdyby obie stały kiedyś pod jedną
|
||||||
|
# domeną, ciasteczka o tej samej nazwie nadpisywałyby się nawzajem i człowiek
|
||||||
|
# wypadałby z jednej aplikacji, logując się do drugiej.
|
||||||
|
COOKIE = "astroklient_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": "/"}
|
||||||
@@ -11,7 +11,20 @@ body {
|
|||||||
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
}
|
}
|
||||||
main { max-width: 880px; margin: 0 auto; padding: 1.5rem 1rem 3rem; }
|
main { max-width: 880px; margin: 0 auto; padding: 1.5rem 1rem 3rem; }
|
||||||
.topbar { padding: .5rem 0 1rem; border-bottom: 1px solid var(--line); }
|
.topbar { padding: .5rem 0 1rem; border-bottom: 1px solid var(--line);
|
||||||
|
display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; }
|
||||||
|
.kto { display: flex; align-items: center; gap: .5rem; }
|
||||||
|
.whoami { color: var(--muted); font-size: .85rem; }
|
||||||
|
.wyloguj-form { display: inline; margin: 0; }
|
||||||
|
button.wyloguj {
|
||||||
|
background: none; border: 1px solid var(--line); color: var(--muted);
|
||||||
|
font: inherit; font-weight: 400; padding: .35rem .8rem; border-radius: 8px; cursor: pointer;
|
||||||
|
}
|
||||||
|
button.wyloguj:hover { color: var(--fg); }
|
||||||
|
|
||||||
|
/* Ekran logowania — wąski, bez niczego poza formularzem. */
|
||||||
|
.login-card label { display: block; margin-top: .75rem; }
|
||||||
|
.login-card input { width: 100%; }
|
||||||
h1 { margin: 0; font-size: 1.4rem; letter-spacing: .5px; }
|
h1 { margin: 0; font-size: 1.4rem; letter-spacing: .5px; }
|
||||||
h2 { margin: 0 0 .3rem; font-size: 1.05rem; }
|
h2 { margin: 0 0 .3rem; font-size: 1.05rem; }
|
||||||
h3 { margin: 0 0 .5rem; font-size: 1rem; }
|
h3 { margin: 0 0 .5rem; font-size: 1rem; }
|
||||||
|
|||||||
@@ -10,6 +10,16 @@
|
|||||||
<main>
|
<main>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<h1>astroklient-demo</h1>
|
<h1>astroklient-demo</h1>
|
||||||
|
{# Kto jest zalogowany i wyjście. Demo bywa używane na cudzych komputerach,
|
||||||
|
więc bez tego pierwszy klient zostawia otwartą sesję drugiemu. #}
|
||||||
|
{% if auth_on() %}
|
||||||
|
<div class="kto">
|
||||||
|
<span class="whoami">{{ current_user(request) }}</span>
|
||||||
|
<form method="post" action="/wyloguj" class="wyloguj-form">
|
||||||
|
<button type="submit" class="wyloguj">Wyloguj</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{# CELOWO BEZ NAWIGACJI. Dwie funkcje mieszczą się na jednym ekranie,
|
{# CELOWO BEZ NAWIGACJI. Dwie funkcje mieszczą się na jednym ekranie,
|
||||||
a pasek zakładek z jedną pozycją tylko sugerowałby, że są inne. #}
|
a pasek zakładek z jedną pozycją tylko sugerowałby, że są inne. #}
|
||||||
</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>astroklient-demo · logowanie</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="login-main">
|
||||||
|
<header class="topbar"><h1>astroklient-demo</h1></header>
|
||||||
|
|
||||||
|
{% if request.query_params.get('wylogowano') %}
|
||||||
|
<p class="ok">Wylogowano.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if blad %}<p class="alert">{{ blad }}</p>{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/logowanie" class="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>
|
||||||
@@ -14,6 +14,8 @@ import base64
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from app import security
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def env(monkeypatch):
|
def env(monkeypatch):
|
||||||
@@ -21,11 +23,20 @@ def env(monkeypatch):
|
|||||||
monkeypatch.delenv("DEMO_USER", raising=False)
|
monkeypatch.delenv("DEMO_USER", raising=False)
|
||||||
monkeypatch.delenv("DEMO_PASSWORD", raising=False)
|
monkeypatch.delenv("DEMO_PASSWORD", 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
|
||||||
|
|
||||||
|
|
||||||
def _auth(user="demo", password="tajne-demo"):
|
def _auth(user="demo", password=""):
|
||||||
return {"Authorization": "Basic " + base64.b64encode(
|
"""Nagłówek z WAŻNĄ SESJĄ — odpowiednik bycia zalogowanym.
|
||||||
f"{user}:{password}".encode()).decode()}
|
|
||||||
|
Po przejściu z Basic na sesje (LOG-34) „zalogowany" znaczy „ma podpisane
|
||||||
|
ciasteczko", nie „ma nagłówek z hasłem". Hasło przyjmowane tylko po to, żeby
|
||||||
|
nie przepisywać wszystkich wywołań — sprawdzanie poświadczeń ma własne testy."""
|
||||||
|
from app import security, session
|
||||||
|
|
||||||
|
return {"Cookie": f"{session.COOKIE}={security.issue_session(user)}"}
|
||||||
|
|
||||||
|
|
||||||
REPORT = {
|
REPORT = {
|
||||||
@@ -75,7 +86,8 @@ def test_the_service_exposes_nothing_beyond_its_two_functions():
|
|||||||
|
|
||||||
trasy = {(m, r.path) for r in app.routes for m in (getattr(r, "methods", None) or set())
|
trasy = {(m, r.path) for r in app.routes for m in (getattr(r, "methods", None) or set())
|
||||||
if not r.path.startswith("/static") and m in {"GET", "POST"}}
|
if not r.path.startswith("/static") and m in {"GET", "POST"}}
|
||||||
assert trasy == {("GET", "/"), ("POST", "/"), ("POST", "/plik"), ("GET", "/health")}
|
assert trasy == {("GET", "/"), ("POST", "/"), ("POST", "/plik"), ("GET", "/health"),
|
||||||
|
("GET", "/logowanie"), ("POST", "/logowanie"), ("POST", "/wyloguj")}
|
||||||
|
|
||||||
|
|
||||||
def test_the_client_can_only_ask_for_what_the_demo_needs():
|
def test_the_client_can_only_ask_for_what_the_demo_needs():
|
||||||
@@ -99,7 +111,9 @@ def test_api_docs_are_off():
|
|||||||
def test_without_credentials_nothing_is_served(env, monkeypatch):
|
def test_without_credentials_nothing_is_served(env, monkeypatch):
|
||||||
c, _ = _client(monkeypatch)
|
c, _ = _client(monkeypatch)
|
||||||
for method, path in (("get", "/"), ("post", "/"), ("post", "/plik")):
|
for method, path in (("get", "/"), ("post", "/"), ("post", "/plik")):
|
||||||
assert getattr(c, method)(path).status_code == 401, path
|
r = getattr(c, method)(path, follow_redirects=False)
|
||||||
|
assert r.status_code == 303, path
|
||||||
|
assert "/logowanie" in r.headers["location"], path
|
||||||
assert c.get("/health").status_code == 200, "sonda musi działać bez hasła"
|
assert c.get("/health").status_code == 200, "sonda musi działać bez hasła"
|
||||||
|
|
||||||
|
|
||||||
@@ -110,15 +124,14 @@ def test_demo_account_is_separate_from_the_main_application(env, monkeypatch):
|
|||||||
monkeypatch.setenv("APP_USER", "astrololo")
|
monkeypatch.setenv("APP_USER", "astrololo")
|
||||||
monkeypatch.setenv("APP_PASSWORD", "haslo-glownej-aplikacji")
|
monkeypatch.setenv("APP_PASSWORD", "haslo-glownej-aplikacji")
|
||||||
c, _ = _client(monkeypatch)
|
c, _ = _client(monkeypatch)
|
||||||
assert c.get("/", headers=_auth("astrololo", "haslo-glownej-aplikacji")).status_code == 401
|
assert security.verify("astrololo", "haslo-glownej-aplikacji") is None
|
||||||
assert c.get("/", headers=_auth()).status_code == 200
|
assert security.verify("demo", "tajne-demo") == "demo"
|
||||||
|
|
||||||
|
|
||||||
def test_a_password_with_polish_letters_is_refused_not_crashed(env, monkeypatch):
|
def test_a_password_with_polish_letters_is_refused_not_crashed(env, monkeypatch):
|
||||||
monkeypatch.setenv("DEMO_USERS", "demo:zażółć-gęślą")
|
monkeypatch.setenv("DEMO_USERS", "demo:zażółć-gęślą")
|
||||||
c, _ = _client(monkeypatch)
|
c, _ = _client(monkeypatch)
|
||||||
assert c.get("/", headers=_auth("demo", "złe-hasło")).status_code == 401
|
|
||||||
assert c.get("/", headers=_auth("demo", "zażółć-gęślą")).status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
# ── funkcja 1: dodanie pliku ─────────────────────────────────────────────
|
# ── funkcja 1: dodanie pliku ─────────────────────────────────────────────
|
||||||
@@ -241,6 +254,44 @@ def test_every_account_has_its_own_pool_by_construction(env, monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
def test_an_unknown_account_gets_nothing(env, monkeypatch):
|
def test_an_unknown_account_gets_nothing(env, monkeypatch):
|
||||||
|
"""Bez ważnej sesji żądanie nie schodzi niżej — nie wystarczy, że skończy
|
||||||
|
się przekierowaniem: nie może po drodze dotknąć warstwy logicznej."""
|
||||||
c, calls = _client(monkeypatch)
|
c, calls = _client(monkeypatch)
|
||||||
assert c.post("/", headers=_auth("obcy", "cokolwiek"), data=FORM).status_code == 401
|
r = c.post("/", data=FORM, follow_redirects=False)
|
||||||
assert calls == [], "poszło zapytanie mimo odmowy logowania"
|
assert r.status_code == 303 and "/logowanie" in r.headers["location"]
|
||||||
|
assert calls == [], "poszło zapytanie mimo braku sesji"
|
||||||
|
|
||||||
|
|
||||||
|
def test_logging_in_and_out_of_the_demo(env, monkeypatch):
|
||||||
|
"""Pełny obieg: demo bywa używane na cudzych komputerach, więc wyjście musi
|
||||||
|
naprawdę kończyć sesję, a nie tylko tak wyglądać."""
|
||||||
|
from app import session
|
||||||
|
|
||||||
|
c, _ = _client(monkeypatch)
|
||||||
|
r = c.post("/logowanie", follow_redirects=False,
|
||||||
|
data={"login": "klientA", "haslo": "hasloA", "dokad": "/"})
|
||||||
|
assert r.status_code == 303 and session.COOKIE in r.cookies
|
||||||
|
|
||||||
|
wyjscie = c.post("/wyloguj", headers=_auth("klientA"), follow_redirects=False)
|
||||||
|
assert wyjscie.status_code == 303
|
||||||
|
ustawione = wyjscie.headers.get("set-cookie", "")
|
||||||
|
assert session.COOKIE in ustawione and "Max-Age=0" in ustawione
|
||||||
|
|
||||||
|
|
||||||
|
def test_removing_an_account_kills_its_open_session(env, monkeypatch):
|
||||||
|
"""Demo nie ma gdzie trwale zapisywać stanu, więc zdalne unieważnienie robi
|
||||||
|
się przez DEMO_USERS — usunięcie albo zmiana hasła ubija sesję odciskiem."""
|
||||||
|
c, _ = _client(monkeypatch)
|
||||||
|
ciasteczko = _auth("klientA")
|
||||||
|
assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 200
|
||||||
|
|
||||||
|
monkeypatch.setenv("DEMO_USERS", "demo:tajne-demo,klientB:hasloB") # bez klientA
|
||||||
|
assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 303
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_demo_login_cannot_be_turned_into_a_redirector(env, monkeypatch):
|
||||||
|
c, _ = _client(monkeypatch)
|
||||||
|
for zly in ("https://obcy.pl", "//obcy.pl", "http://obcy.pl/x"):
|
||||||
|
r = c.post("/logowanie", follow_redirects=False,
|
||||||
|
data={"login": "demo", "haslo": "tajne-demo", "dokad": zly})
|
||||||
|
assert r.headers["location"] == "/", f"przepuszczono: {zly}"
|
||||||
|
|||||||
Reference in New Issue
Block a user