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

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:
2026-08-21 00:21:52 +02:00
parent 222963d105
commit fee102f2ea
7 changed files with 452 additions and 32 deletions
+56 -1
View File
@@ -27,7 +27,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
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
# 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")
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:
"""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)
# ---------------- 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")
def health() -> dict:
return {"status": "ok", "layer": "astroklient-demo"}