feat: wire auth into the web layer (Schritt 2, part 2/2)

Registration creates a new account plus its first user; login
authenticates an existing one; both set a deklarix_session cookie
(HttpOnly, SameSite=Strict, Secure only when the request itself came
over TLS — hardcoding Secure=true would break local http://localhost
development, since browsers won't store a Secure cookie over plaintext).

requirePage protects full-page GETs (redirects to /login); requireAPI
protects the htmx/download endpoints (401, since those are only ever
called from an already-authenticated page — an unauthenticated hit
there is the exception, e.g. a session expiring mid-use).

handleCheck now creates submissions under the current account.
handleArchive and handleDossierDownload compare the submission's
account against the caller's and return 404 on mismatch — not 403,
which would confirm the ID exists to a different tenant. Login failure
uses the same message for "no such email" and "wrong password" to avoid
account enumeration.

Restructured templates along the way: layout.html now only holds
reusable fragments ("head", "nav"); each full page (index/login/register)
is its own top-level named template. The previous layout+content nesting
would have broken the moment a second page defined "content" — Go's
html/template keys blocks by name across the whole parsed set, not per
file, so two pages both defining "content" would silently overwrite each
other.

Verified against a real running instance (not just Go's test recorder):
started the compiled binary against a fresh Postgres and drove the whole
flow with curl — anonymous redirect, registration setting a real cookie,
authenticated page load, logout clearing both the cookie and the
server-side session row, and being locked out again afterward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
noroot
2026-08-27 15:56:11 +02:00
parent 2a16dc2200
commit 5156fe62da
12 changed files with 755 additions and 128 deletions

View File

@@ -1,10 +1,8 @@
// Package web ist die HTTP-Schicht: Routing, Templates, Handler.
// Bewusst html/template + htmx, kein Frontend-Build (siehe CLAUDE.md,
// Stack). Persistenz (Submission/Finding/Evidence speichern) ist noch
// nicht angebunden — das ist der nächste Schritt (Verdrahtung über
// internal/store). Dieser Server führt Pre-Publish-Prüfungen aus
// (extrahieren + bewerten) und zeigt das Ergebnis an, ohne es
// aufzubewahren.
// Stack). Jede Submission gehört einem Account (Mandant); Auth-Cookies
// binden einen Request an einen angemeldeten Nutzer und damit an dessen
// Account — siehe middleware.go.
package web
import (
@@ -13,6 +11,7 @@ import (
"fmt"
"html/template"
"net/http"
"time"
"github.com/netcell-it/deklarix/internal/evidence"
"github.com/netcell-it/deklarix/internal/extract"
@@ -39,7 +38,7 @@ type Extractor interface {
// komplette *store.Store. *store.Store erfüllt sie; Tests injizieren
// einen Fake statt eine echte Datenbank zu brauchen.
type Store interface {
CreateSubmission(ctx context.Context, platform, postType, caption string) (store.Submission, error)
CreateSubmission(ctx context.Context, accountID, platform, postType, caption string) (store.Submission, error)
GetSubmission(ctx context.Context, id string) (store.Submission, error)
SetSubmissionStatus(ctx context.Context, id, status string) error
CreateExtraction(ctx context.Context, submissionID string, payload []byte, modelVersion, promptVersion string) (store.Extraction, error)
@@ -48,6 +47,14 @@ type Store interface {
ListCurrentFindings(ctx context.Context, submissionID string) ([]store.Finding, error)
CreateEvidencePackage(ctx context.Context, submissionID, dossierPath, sha256Hex string, timestampToken []byte) (store.EvidencePackage, error)
GetLatestEvidencePackage(ctx context.Context, submissionID string) (store.EvidencePackage, error)
CreateAccount(ctx context.Context, name string) (store.Account, error)
CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error)
GetUserByEmail(ctx context.Context, email string) (store.User, error)
GetUser(ctx context.Context, id string) (store.User, error)
CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error)
GetSession(ctx context.Context, token string) (store.Session, error)
DeleteSession(ctx context.Context, token string) error
}
// Server bündelt Routing und Abhängigkeiten der Web-Schicht.
@@ -81,10 +88,15 @@ func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper
mux := http.NewServeMux()
mux.HandleFunc("GET /health", s.handleHealth)
mux.HandleFunc("GET /{$}", s.handleIndex)
mux.HandleFunc("POST /pruefen", s.handleCheck)
mux.HandleFunc("POST /veroeffentlichen", s.handleArchive)
mux.HandleFunc("GET /dossier/{id}", s.handleDossierDownload)
mux.HandleFunc("GET /register", s.handleRegisterForm)
mux.HandleFunc("POST /register", s.handleRegister)
mux.HandleFunc("GET /login", s.handleLoginForm)
mux.HandleFunc("POST /login", s.handleLogin)
mux.HandleFunc("POST /logout", s.handleLogout)
mux.HandleFunc("GET /{$}", s.requirePage(s.handleIndex))
mux.HandleFunc("POST /pruefen", s.requireAPI(s.handleCheck))
mux.HandleFunc("POST /veroeffentlichen", s.requireAPI(s.handleArchive))
mux.HandleFunc("GET /dossier/{id}", s.requireAPI(s.handleDossierDownload))
mux.Handle("GET /static/", http.FileServerFS(staticFS))
s.mux = mux