diff --git a/docs/astrololo_wymagania.xlsx b/docs/astrololo_wymagania.xlsx
index a847a59..656a3cf 100644
Binary files a/docs/astrololo_wymagania.xlsx and b/docs/astrololo_wymagania.xlsx differ
diff --git a/services/presentation/app/accounts.py b/services/presentation/app/accounts.py
index 6eed557..685bfc6 100644
--- a/services/presentation/app/accounts.py
+++ b/services/presentation/app/accounts.py
@@ -121,6 +121,7 @@ def all_users() -> dict[str, dict]:
for login, row in _read()["users"].items():
out[login] = {
"features": sorted(features.normalise(row.get("features"))),
+ "session_version": int(row.get("session_version") or 0),
"note": str(row.get("note") 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")
+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]:
return features.normalise((_read()["users"].get(login) or {}).get("features"))
diff --git a/services/presentation/app/features.py b/services/presentation/app/features.py
index 7fc1700..192097e 100644
--- a/services/presentation/app/features.py
+++ b/services/presentation/app/features.py
@@ -111,7 +111,10 @@ ROUTES: dict[tuple[str, str], str | None] = {
("GET", "/health"): None,
# 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ść.
- ("GET", "/wyloguj"): None,
+ ("GET", "/logowanie"): None,
+ ("POST", "/logowanie"): None,
+ ("POST", "/wyloguj"): None,
+ ("POST", "/accounts/sesje"): ADMIN,
}
diff --git a/services/presentation/app/main.py b/services/presentation/app/main.py
index 4ef4c07..d3860ae 100644
--- a/services/presentation/app/main.py
+++ b/services/presentation/app/main.py
@@ -27,6 +27,7 @@ import base64
from app import accounts as accounts_store
from app import chartwheel as chartwheel_mod
from app import features
+from app import session
from app import files_state
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}”.")
+@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")
def accounts_delete(request: Request, login: str = Form(...)):
try:
@@ -882,46 +897,69 @@ def accounts_delete(request: Request, login: str = Form(...)):
return _accounts_redirect(done=f"Skasowano konto „{login}”.")
-# ---------------- Wylogowanie (LOG-32) ----------------
-# HTTP Basic NIE MA prawdziwego wylogowania i trzeba to powiedzieć wprost:
-# przeglądarka zapamiętuje dane logowania i dosyła je sama przy każdym żądaniu,
-# więc „zapomnienie" po stronie serwera nic nie daje — kolejne kliknięcie weszłoby
-# 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.
+# ---------------- Logowanie i wylogowanie (LOG-34) ----------------
+# Zastępuje HTTP Basic, który nie miał wylogowania: przeglądarka zapamiętywała
+# hasło i dosyłała je sama, więc serwer nie miał czego zapomnieć. Teraz to serwer
+# decyduje, czy dana przeglądarka jest w środku — i może to cofnąć.
-@app.get("/wyloguj", response_class=HTMLResponse)
-def wyloguj() -> HTMLResponse:
- """Kończy sesję przeglądarki. Zawsze 401 — to jest tu WYNIK, nie awaria."""
- strona = (
- '
Jeśli komputer jest współdzielony, zamknij okno '
- 'przeglądarki — to jedyny sposób pewny w każdej przeglądarce.
'
- ''
- )
- return HTMLResponse(
- strona, status_code=401,
- headers={"WWW-Authenticate": 'Basic realm="astrololo"',
- # Bez tego przeglądarka mogłaby oddać tę stronę z pamięci
- # podręcznej przy kolejnym wylogowaniu, nie pytając serwera —
- # a wtedy okienko logowania w ogóle by się nie pojawiło.
- "Cache-Control": "no-store, no-cache, must-revalidate",
- "Pragma": "no-cache"},
- )
+def _bezpieczne_dokad(dokad: str) -> str:
+ """Adres powrotu po zalogowaniu, przepuszczony przez sito.
+
+ Bez tego `?dokad=https://obcy.pl` zamieniłby nasz ekran logowania w narzędzie
+ do przekierowywania ludzi gdzie indziej — klasyczna droga do wyłudzenia hasła,
+ bo odsyłacz prowadzi z zaufanego adresu. Wpuszczamy wyłącznie ścieżki
+ wewnętrzne: jeden ukośnik na początku i ani jednego znaku, który mógłby
+ zacząć adres obcego serwera."""
+ 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 = ""):
+ # Zalogowanego nie ma po co pytać o hasło jeszcze raz.
+ if security.auth_enabled() and security.principal(request) is not None:
+ 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)
+ 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")
diff --git a/services/presentation/app/security.py b/services/presentation/app/security.py
index 4d9e166..a56b3a8 100644
--- a/services/presentation/app/security.py
+++ b/services/presentation/app/security.py
@@ -45,7 +45,7 @@ from collections import deque
from dataclasses import dataclass
from fastapi import Request
-from fastapi.responses import HTMLResponse, JSONResponse
+from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
log = logging.getLogger("astrololo.security")
@@ -114,6 +114,15 @@ def rate_limit_per_min() -> int:
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:
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
# 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ę.
-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/",)
_hits: dict[str, deque[float]] = {}
@@ -146,53 +157,99 @@ class Principal:
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
- o koncie administracyjnym. Przeciwnie byłoby cichą pułapką: nowa funkcja
- byłaby niewidoczna dla jedynej osoby, która ma ją komu nadać."""
+ Liczone z katalogu, więc dopisanie funkcji NIE wymaga pamiętania o koncie
+ administracyjnym. Przeciwnie byłoby cichą pułapką: nowa funkcja byłaby
+ niewidoczna dla jedynej osoby, która ma ją komu nadać."""
from app import features
return frozenset(features.GRANTABLE) | {features.ADMIN}
-def principal(header: str | None) -> Principal | None:
- """Nagłówek Basic → kto to jest i co mu wolno; None, gdy dane nie pasują.
+def _admin_spec(login: str) -> str | None:
+ """Sekret konta administracyjnego z KONFIGURACJI ŚRODOWISKA albo None.
- Kolejność: najpierw konta z KONFIGURACJI ŚRODOWISKA (administracyjne, pełne
- uprawnienia), potem konta zarządzane z ekranu. Nie odwrotnie — inaczej
- założenie w pliku konta o loginie administratora przesłoniłoby konto
- administracyjne i odebrało mu uprawnienia.
+ Sprawdzane ZAWSZE PIERWSZE i bez dotykania pliku kont — to jest cała
+ separacja konta administracyjnego (LOG-34):
- Nieznany login i złe hasło traktujemy tak samo: odmowa nie mówi, które konto
- istnieje."""
- if not header or not header.lower().startswith("basic "):
- return None
- try:
- raw = base64.b64decode(header.split(" ", 1)[1]).decode("utf-8")
- user, _, password = raw.partition(":")
- except (binascii.Error, UnicodeDecodeError, IndexError):
- return None
+ * konto z pliku o tym samym loginie nie przesłoni administratora, bo do
+ pliku w ogóle nie zaglądamy, gdy login pasuje do konfiguracji,
+ * administrator zaloguje się nawet wtedy, gdy plik kont jest uszkodzony
+ albo nieczytelny — 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ść.
+ """
+ return accounts().get(login)
- 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:
- return (Principal(user, _admin_permissions(), True)
- if _secret_matches(spec, password) else None)
+ return Principal(login, _admin_permissions(), True) if _secret_matches(spec, password) else None
from app import accounts as store
- managed = store.secret_of(user)
- if managed is None:
+ try:
+ 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
- if not _secret_matches(managed, password):
+ if zapisany is None or not _secret_matches(zapisany, password):
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:
- """Sam LOGIN — do dziennika audytowego (PRE-17) i zgodności wstecz."""
- who = principal(header)
- return who.login if who else None
+def issue_session(login: str) -> str:
+ """Zawartość ciasteczka dla właśnie zalogowanego konta.
+
+ 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:
@@ -270,6 +327,22 @@ def audit_records(request: Request, count: int) -> None:
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:
records = getattr(request.state, "audit_records", None)
audit_log.info(
@@ -280,8 +353,20 @@ def _audit(request: Request, user: str, status: int, ms: float) -> 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()
+
+ # 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():
log.warning(
"UWAGA: brak kont (APP_USERS / APP_PASSWORD) — aplikacja jest OTWARTA "
@@ -311,16 +396,27 @@ def install(app) -> None:
from app import features
- user, who = "", None
+ user, who, odswiez = "", None, None
if auth_enabled():
- who = principal(request.headers.get("authorization"))
+ who = principal(request)
if who is None:
- _audit(request, "-", 401, (time.monotonic() - started) * 1000)
- return HTMLResponse(
- "
401 — wymagane logowanie
", status_code=401,
- headers={"WWW-Authenticate": 'Basic realm="astrololo"'},
- )
+ _audit(request, "-", 303, (time.monotonic() - started) * 1000)
+ # Przekierowanie na ekran logowania, nie 401: 401 z Basic otwierało
+ # okienko systemowe, którego nie da się ani opisać, ani z niego wyjść.
+ # 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
+ # 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:
# 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.
@@ -336,5 +432,10 @@ def install(app) -> None:
_audit(request, user, 404, (time.monotonic() - started) * 1000)
return HTMLResponse("
404 — nie znaleziono
", status_code=404)
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)
return response
diff --git a/services/presentation/app/session.py b/services/presentation/app/session.py
new file mode 100644
index 0000000..7204858
--- /dev/null
+++ b/services/presentation/app/session.py
@@ -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": "/"}
diff --git a/services/presentation/app/static/styles.css b/services/presentation/app/static/styles.css
index ce121dc..e42efc9 100644
--- a/services/presentation/app/static/styles.css
+++ b/services/presentation/app/static/styles.css
@@ -271,5 +271,17 @@ tr.row-held td { background: rgba(184, 134, 11, .07); }
rozpychać. */
.whoami { color: var(--muted); font-size: .85rem; align-self: center;
margin-left: 1rem; padding-left: 1rem; border-left: 1px solid var(--line); }
-nav a.wyloguj { color: var(--muted); }
-nav a.wyloguj:hover { color: var(--fg, inherit); }
+/* Wylogowanie idzie POST-em, więc jest przyciskiem — ale ma wyglądać jak
+ 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; }
diff --git a/services/presentation/app/static/wyloguj.js b/services/presentation/app/static/wyloguj.js
deleted file mode 100644
index 6c560d1..0000000
--- a/services/presentation/app/static/wyloguj.js
+++ /dev/null
@@ -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;
- });
- });
- });
-})();
diff --git a/services/presentation/app/templates/accounts.html b/services/presentation/app/templates/accounts.html
index afba6b8..a8e1b39 100644
--- a/services/presentation/app/templates/accounts.html
+++ b/services/presentation/app/templates/accounts.html
@@ -53,6 +53,10 @@