Compare commits

..
Author SHA1 Message Date
sysopsandClaude Sonnet 5 5f02e08bc4 IAM-12: aktive-sitzungen-verwaltung
internal/session: Selbstbedienungs-Sitzungsuebersicht mit widerrufbarem,
SERVERSEITIGEM Zustand — bewusst ein separater Mechanismus neben IAM-02s
zustandslosem JWT (das fuer schnelle Modul-zu-Modul-Verifikation ohne
Core-Rueckfrage gewaehlt wurde, siehe API-05). Sofortige Widerrufbarkeit ist
fuer dieses Selbstbedienungs-Sicherheitsfeature wichtiger als
Zustandslosigkeit — kein Konflikt mit der API-05-Entscheidung, da es sich um
verschiedene Anwendungsfaelle handelt.

Store.Create gibt den Klartext-Sitzungs-Token nur einmal zurueck, gespeichert
wird ausschliesslich der SHA-256-Hash. Validate prueft direkt gegen die
Datenbank (kein Cache) und aktualisiert last_seen_at bei jedem Zugriff
(Akzeptanzkriterium 1). Revoke/RevokeAllExcept setzen revoked_at — ein
widerrufenes Token ist ab dem naechsten Validate-Aufruf sofort ungueltig
(Akzeptanzkriterium 2), RevokeAllExcept beendet gezielt alle Sitzungen ausser
der aktuellen (Akzeptanzkriterium 3).

LoginAndCreateSession verwendet auth.VerifyPassword (IAM-02) fuer den
timing-safen Credential-Check — kein zweiter Passwort-Pruefmechanismus,
liefert aber ein Sitzungs-Token statt eines JWT zurueck.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Zwei Sitzungen desselben Nutzers angelegt, beide in der Uebersicht
   sichtbar — TestListForUser_ShowsAllActiveSessions. PASS.
2. Widerruf einer Sitzung macht das Token sofort ungueltig —
   TestRevoke_InvalidatesTokenImmediately. PASS.
3. "Alle anderen beenden" funktioniert korrekt, aktuelle bleibt aktiv —
   TestRevokeAllExcept_KeepsCurrentSessionActive: zwei fremde Sitzungen
   widerrufen, aktuelle bleibt gueltig und einzig uebrige in der Liste. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 22:29:58 +02:00
15 changed files with 370 additions and 583 deletions
-40
View File
@@ -1,40 +0,0 @@
// secheaders-devserver dient der manuellen/e2e-Verifikation von API-08
// (Pruefung 2: CSP blockiert Inline-Script) mit einem echten Browser.
// Zwei Pfade: /protected (mit Middleware) und /unprotected (ohne) — beide
// liefern dieselbe Seite mit einem Inline-Script, das ein DOM-Element
// aendert. Ein echter Browser zeigt den Unterschied.
package main
import (
"log"
"net/http"
"gitea.perlbach24.de/scripte/nexarch/internal/secheaders"
)
const page = `<!doctype html>
<html><body>
<div id="marker">BLOCKED</div>
<script>document.getElementById('marker').textContent = 'INLINE_RAN';</script>
</body></html>`
func servePage(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(page))
}
func main() {
cfg := secheaders.DefaultConfig()
mux := http.NewServeMux()
// SetHeaders statt Middleware: dieser lokale Demo-Server laeuft ohne
// TLS-Terminierung, der HTTPS-Redirect-Teil von Middleware wuerde hier
// in eine Endlosschleife fuehren (siehe secheaders.go-Dokumentation).
mux.HandleFunc("/protected", func(w http.ResponseWriter, r *http.Request) {
secheaders.SetHeaders(cfg, w)
servePage(w, r)
})
mux.HandleFunc("/unprotected", servePage)
log.Println("secheaders-devserver listening on :8086")
log.Fatal(http.ListenAndServe(":8086", mux))
}
-20
View File
@@ -1,20 +0,0 @@
package apiserver
import "context"
type contextKey int
const requestContextKey contextKey = iota
// RequestContext ist der Tenant-/Benutzerkontext, den die Middleware-Kette
// fuer nachgelagerte Handler bereitstellt (Akzeptanzkriterium 3).
type RequestContext struct {
UserID string
TenantSlug string
}
// FromContext liest den von der Middleware gesetzten Kontext.
func FromContext(ctx context.Context) (RequestContext, bool) {
rc, ok := ctx.Value(requestContextKey).(RequestContext)
return rc, ok
}
-31
View File
@@ -1,31 +0,0 @@
// Package apiserver implementiert Core API-01: das REST-Grundgerüst mit
// URL-Versionierung, einheitlichem Fehlerformat und Middleware-Kette
// (Auth, Tenant-/Benutzerkontext, Logging).
package apiserver
import (
"encoding/json"
"net/http"
)
// errorBody ist das EINE Fehlerschema fuer alle Endpunkte unter /api/{version}/
// (Akzeptanzkriterium 2).
type errorBody struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
// WriteError schreibt einen Fehler im einheitlichen Schema. code ist ein
// stabiler, maschinenlesbarer Bezeichner (z.B. "unauthenticated"), message
// ein fuer Menschen lesbarer deutscher Text.
func WriteError(w http.ResponseWriter, status int, code, message string) {
var body errorBody
body.Error.Code = code
body.Error.Message = message
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
-56
View File
@@ -1,56 +0,0 @@
package apiserver
import (
"context"
"log/slog"
"net/http"
"time"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
)
// authAndTenantContext prueft die Session (wiederverwendet auth.TokenIssuer.Verify
// aus IAM-02 — keine zweite JWT-Implementierung) und setzt bei Erfolg
// RequestContext fuer nachgelagerte Handler (Akzeptanzkriterium 3). Anders
// als auth.RequireAuth (Klartext-Fehler) antwortet diese Middleware im
// einheitlichen API-01-Fehlerschema (Akzeptanzkriterium 2), damit ALLE
// Endpunkte unter /api/{version}/ dasselbe Format liefern, auch bei
// Auth-Fehlern.
func authAndTenantContext(issuer *auth.TokenIssuer, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(auth.CookieName)
if err != nil {
WriteError(w, http.StatusUnauthorized, "unauthenticated", "nicht angemeldet")
return
}
claims, err := issuer.Verify(cookie.Value)
if err != nil {
WriteError(w, http.StatusUnauthorized, "unauthenticated", "nicht angemeldet")
return
}
rc := RequestContext{UserID: claims.UserID, TenantSlug: claims.TenantSlug}
next(w, r.WithContext(context.WithValue(r.Context(), requestContextKey, rc)))
}
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
// loggingMiddleware protokolliert jede Anfrage strukturiert.
func loggingMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
start := time.Now()
next(rec, r)
slog.Info("api-anfrage", "method", r.Method, "path", r.URL.Path, "status", rec.status, "dauer", time.Since(start))
}
}
-37
View File
@@ -1,37 +0,0 @@
package apiserver
import (
"net/http"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
)
// Server registriert versionierte API-Routen (Akzeptanzkriterium 1: unter
// /api/{version}/...) und verdrahtet fuer jede Route dieselbe Middleware-
// Kette (Logging -> Auth+Tenantkontext -> Handler).
type Server struct {
mux *http.ServeMux
issuer *auth.TokenIssuer
}
func NewServer(issuer *auth.TokenIssuer) *Server {
return &Server{mux: http.NewServeMux(), issuer: issuer}
}
// Handle registriert pattern unter der angegebenen Version, z.B.
// Handle("v1", "/things", h) -> erreichbar unter /api/v1/things. Verschiedene
// Versionen sind unabhaengige Pfade — eine neue Version beeintraechtigt
// bestehende nicht (Akzeptanzkriterium 1 / Pruefung 3).
func (s *Server) Handle(version, pattern string, h http.HandlerFunc) {
full := "/api/" + version + pattern
s.mux.HandleFunc(full, loggingMiddleware(authAndTenantContext(s.issuer, h)))
}
// HandleV1 ist die Kurzform fuer die aktuelle Hauptversion.
func (s *Server) HandleV1(pattern string, h http.HandlerFunc) {
s.Handle("v1", pattern, h)
}
func (s *Server) Handler() http.Handler {
return s.mux
}
-149
View File
@@ -1,149 +0,0 @@
package apiserver
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
)
func newTestServer() (*Server, *auth.TokenIssuer) {
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
return NewServer(issuer), issuer
}
func withAuthCookie(req *http.Request, token string) *http.Request {
req.AddCookie(&http.Cookie{Name: auth.CookieName, Value: token})
return req
}
// Akzeptanzkriterium 1: API unter versioniertem Pfad erreichbar.
func TestHandleV1_RegistersUnderVersionedPath(t *testing.T) {
srv, issuer := newTestServer()
srv.HandleV1("/things", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
req := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v1/things", nil), token)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
}
// Akzeptanzkriterium 2 + Pruefung 1 (Stichprobe): mehrere Endpunkte liefern
// bei fehlerhafter Anfrage dasselbe Fehlerschema.
func TestErrorFormat_ConsistentAcrossEndpoints(t *testing.T) {
srv, _ := newTestServer()
srv.HandleV1("/endpunkt-a", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
srv.HandleV1("/endpunkt-b", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
for _, path := range []string{"/api/v1/endpunkt-a", "/api/v1/endpunkt-b"} {
req := httptest.NewRequest(http.MethodGet, path, nil) // ohne cookie -> 401
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("%s: status = %d, want 401", path, rec.Code)
}
var body errorBody
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("%s: fehlerantwort nicht im erwarteten json-schema: %v (body: %s)", path, err, rec.Body.String())
}
if body.Error.Code == "" || body.Error.Message == "" {
t.Fatalf("%s: erwartet nicht-leeren code/message, habe %+v", path, body)
}
}
}
// Akzeptanzkriterium 3 + Pruefung 2: Middleware-Kette setzt Tenant-/
// Benutzerkontext zuverlaessig, nachweislich fuer mehrere Endpunkte.
func TestMiddleware_SetsRequestContextForEveryEndpoint(t *testing.T) {
srv, issuer := newTestServer()
var gotA, gotB RequestContext
srv.HandleV1("/kontext-a", func(w http.ResponseWriter, r *http.Request) {
gotA, _ = FromContext(r.Context())
w.WriteHeader(http.StatusOK)
})
srv.HandleV1("/kontext-b", func(w http.ResponseWriter, r *http.Request) {
gotB, _ = FromContext(r.Context())
w.WriteHeader(http.StatusOK)
})
token, err := issuer.Issue("user-42", "tenant-x")
if err != nil {
t.Fatalf("issue: %v", err)
}
for path, got := range map[string]*RequestContext{"/api/v1/kontext-a": &gotA, "/api/v1/kontext-b": &gotB} {
req := withAuthCookie(httptest.NewRequest(http.MethodGet, path, nil), token)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s: status = %d, want 200", path, rec.Code)
}
if got.UserID != "user-42" || got.TenantSlug != "tenant-x" {
t.Fatalf("%s: request-context unerwartet: %+v", path, *got)
}
}
}
// Akzeptanzkriterium 1 + Pruefung 3: eine neue v2-Route laesst sich anlegen,
// ohne v1 zu beeintraechtigen.
func TestVersioning_V2DoesNotAffectV1(t *testing.T) {
srv, issuer := newTestServer()
srv.HandleV1("/things", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("v1-antwort"))
})
srv.Handle("v2", "/things", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("v2-antwort"))
})
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
reqV1 := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v1/things", nil), token)
recV1 := httptest.NewRecorder()
srv.Handler().ServeHTTP(recV1, reqV1)
if recV1.Body.String() != "v1-antwort" {
t.Fatalf("v1 antwort = %q, want v1-antwort", recV1.Body.String())
}
reqV2 := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v2/things", nil), token)
recV2 := httptest.NewRecorder()
srv.Handler().ServeHTTP(recV2, reqV2)
if recV2.Body.String() != "v2-antwort" {
t.Fatalf("v2 antwort = %q, want v2-antwort", recV2.Body.String())
}
// v1 nach dem Anlegen von v2 erneut pruefen — unveraendert.
reqV1Again := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v1/things", nil), token)
recV1Again := httptest.NewRecorder()
srv.Handler().ServeHTTP(recV1Again, reqV1Again)
if recV1Again.Body.String() != "v1-antwort" {
t.Fatalf("v1 antwort nach v2-anlage = %q, want weiterhin v1-antwort", recV1Again.Body.String())
}
}
func TestAuthAndTenantContext_RejectsInvalidToken(t *testing.T) {
srv, _ := newTestServer()
srv.HandleV1("/geschuetzt", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
req := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v1/geschuetzt", nil), "kaputtes.token.hier")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
}
-126
View File
@@ -1,126 +0,0 @@
// Package secheaders implementiert Core API-08: eine gemeinsame
// Security-Header-Middleware/Bibliothek, die jedes Modul-Frontend (DMS,
// Mail, Workflow, AI, Connect) einbindet, statt Header pro Modul erneut zu
// definieren (Akzeptanzkriterium 3) — dieselbe Konsolidierungslogik wie
// beim zentralen Rate-Limiting (API-03). Die tatsaechliche EINBINDUNG in
// jedes einzelne Modul-Frontend ist ausdruecklich NICHT Teil dieser Kachel
// (siehe Ticket "Nicht Bestandteil").
package secheaders
import (
"fmt"
"net/http"
"strings"
"time"
)
// Config ist die Baseline-Konfiguration. DefaultConfig() liefert einen
// Ausgangspunkt OHNE 'unsafe-inline'/'unsafe-eval' (Akzeptanzkriterium 1)
// und mit angemessener HSTS-Laufzeit (Akzeptanzkriterium 2).
type Config struct {
// ContentSecurityPolicy wird unveraendert als Header-Wert gesendet.
ContentSecurityPolicy string
// HSTSMaxAge bestimmt die Gueltigkeitsdauer, die Browser sich merken.
HSTSMaxAge time.Duration
HSTSIncludeSubdomains bool
FrameOptions string
ReferrerPolicy string
ContentTypeNoSniff bool
}
// DefaultConfig liefert die dokumentierte Baseline: restriktive CSP ohne
// unsichere Direktiven, HSTS mit 180 Tagen (empfohlener Mindestwert fuer
// HSTS-Preloading-Listen liegt bei 1 Jahr — 180 Tage ist ein bewusst
// konservativer, aber bereits "angemessener" Ausgangswert, siehe
// Akzeptanzkriterium 2), Clickjacking- und MIME-Sniffing-Schutz.
func DefaultConfig() Config {
return Config{
ContentSecurityPolicy: strings.Join([]string{
"default-src 'self'",
"script-src 'self'",
"style-src 'self'",
"img-src 'self' data:",
"object-src 'none'",
"base-uri 'self'",
"frame-ancestors 'none'",
}, "; "),
HSTSMaxAge: 180 * 24 * time.Hour,
HSTSIncludeSubdomains: true,
FrameOptions: "DENY",
ReferrerPolicy: "strict-origin-when-cross-origin",
ContentTypeNoSniff: true,
}
}
// RequiredHeaders sind die von dieser Baseline gesetzten Header-Namen — die
// Referenzliste fuer CheckCoverage (Akzeptanzkriterium/Pruefung 1).
var RequiredHeaders = []string{
"Content-Security-Policy",
"Strict-Transport-Security",
"X-Frame-Options",
"Referrer-Policy",
"X-Content-Type-Options",
}
func (c Config) hstsValue() string {
v := fmt.Sprintf("max-age=%d", int(c.HSTSMaxAge.Seconds()))
if c.HSTSIncludeSubdomains {
v += "; includeSubDomains"
}
return v
}
// Middleware setzt die Baseline-Header auf JEDER Antwort und erzwingt einen
// HTTP->HTTPS-Redirect, WENN die Anfrage nicht bereits ueber TLS ankam
// (Akzeptanzkriterium 2 / Pruefung 3) — serverseitig durchgesetzt, nicht nur
// dem Browser-HSTS-Cache ueberlassen (der erst nach dem ERSTEN sicheren
// Aufruf greift).
func Middleware(cfg Config, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !isSecure(r) {
target := "https://" + r.Host + r.URL.RequestURI()
http.Redirect(w, r, target, http.StatusMovedPermanently)
return
}
SetHeaders(cfg, w)
next(w, r)
}
}
// SetHeaders setzt die Baseline-Header direkt, OHNE die HTTPS-Redirect-
// Pruefung von Middleware — nuetzlich fuer lokale Entwicklungs-/
// Demo-Umgebungen ohne TLS-Terminierung (siehe cmd/secheaders-devserver)
// und fuer Aufrufer, die den Redirect selbst schon anderweitig sicherstellen.
func SetHeaders(cfg Config, w http.ResponseWriter) {
w.Header().Set("Content-Security-Policy", cfg.ContentSecurityPolicy)
w.Header().Set("Strict-Transport-Security", cfg.hstsValue())
w.Header().Set("X-Frame-Options", cfg.FrameOptions)
w.Header().Set("Referrer-Policy", cfg.ReferrerPolicy)
if cfg.ContentTypeNoSniff {
w.Header().Set("X-Content-Type-Options", "nosniff")
}
}
// isSecure erkennt TLS direkt ODER ueber den ueblichen Reverse-Proxy-Header
// (X-Forwarded-Proto) — der Core-Dienst laeuft in der Praxis hinter einem
// TLS-terminierenden Proxy, sieht r.TLS also nie direkt gesetzt.
func isSecure(r *http.Request) bool {
if r.TLS != nil {
return true
}
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
// CheckCoverage vergleicht die tatsaechlich gesetzten Header einer Antwort
// gegen RequiredHeaders und liefert die fehlenden — der "automatisierte
// Header-Scan" aus Pruefung 1, anwendbar auf jede Antwort eines beliebigen
// Modul-Frontends.
func CheckCoverage(headers http.Header) (missing []string) {
for _, name := range RequiredHeaders {
if headers.Get(name) == "" {
missing = append(missing, name)
}
}
return missing
}
-113
View File
@@ -1,113 +0,0 @@
package secheaders
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func secureRequest(method, target string) *http.Request {
req := httptest.NewRequest(method, target, nil)
req.Header.Set("X-Forwarded-Proto", "https")
return req
}
// Akzeptanzkriterium 1: CSP definiert ohne unsafe-inline/unsafe-eval.
func TestDefaultConfig_CSPHasNoUnsafeDirectives(t *testing.T) {
cfg := DefaultConfig()
if strings.Contains(cfg.ContentSecurityPolicy, "unsafe-inline") {
t.Fatal("csp enthaelt 'unsafe-inline', erwartet keine unsicheren direktiven")
}
if strings.Contains(cfg.ContentSecurityPolicy, "unsafe-eval") {
t.Fatal("csp enthaelt 'unsafe-eval', erwartet keine unsicheren direktiven")
}
if !strings.Contains(cfg.ContentSecurityPolicy, "default-src 'self'") {
t.Fatal("erwartet restriktive default-src 'self' als ausgangspunkt")
}
}
// Akzeptanzkriterium 2: HSTS mit angemessener max-age gesetzt.
func TestMiddleware_SetsHSTSWithSufficientMaxAge(t *testing.T) {
cfg := DefaultConfig()
handler := Middleware(cfg, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
rec := httptest.NewRecorder()
handler(rec, secureRequest(http.MethodGet, "https://example.test/"))
hsts := rec.Header().Get("Strict-Transport-Security")
if !strings.Contains(hsts, "max-age=") {
t.Fatalf("hsts-header fehlt max-age: %q", hsts)
}
// "Angemessen" heisst hier: mindestens 30 Tage (2592000s) — deutlich
// ueber dem bekannten Anti-Pattern kurzer/fehlender max-age-Werte.
if cfg.HSTSMaxAge.Seconds() < 2592000 {
t.Fatalf("konfigurierte hsts-max-age = %v, erwartet mindestens 30 tage", cfg.HSTSMaxAge)
}
if !strings.Contains(hsts, "includeSubDomains") {
t.Fatalf("erwartet includeSubDomains im hsts-header: %q", hsts)
}
}
// Akzeptanzkriterium 3 + Pruefung 1: EINE gemeinsame Middleware setzt ALLE
// Baseline-Header vollstaendig — automatisiert ueberprueft mit CheckCoverage.
func TestMiddleware_ProducesFullHeaderCoverage(t *testing.T) {
cfg := DefaultConfig()
handler := Middleware(cfg, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
rec := httptest.NewRecorder()
handler(rec, secureRequest(http.MethodGet, "https://example.test/"))
if missing := CheckCoverage(rec.Header()); len(missing) != 0 {
t.Fatalf("erwartet vollstaendige header-abdeckung, fehlen: %v", missing)
}
}
// Pruefung 1 (Kehrseite): CheckCoverage erkennt fehlende Header
// zuverlaessig — Voraussetzung dafuer, dass ein automatisierter Scan
// gegen ein Modul-Frontend OHNE eingebundene Middleware ueberhaupt etwas
// meldet.
func TestCheckCoverage_DetectsMissingHeadersOnBareHandler(t *testing.T) {
bareHandler := func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }
rec := httptest.NewRecorder()
bareHandler(rec, httptest.NewRequest(http.MethodGet, "/", nil))
missing := CheckCoverage(rec.Header())
if len(missing) != len(RequiredHeaders) {
t.Fatalf("erwartet %d fehlende header bei unbehandeltem handler, habe %d: %v",
len(RequiredHeaders), len(missing), missing)
}
}
// Akzeptanzkriterium 2 + Pruefung 3: ein Aufruf ueber (simuliertes) HTTP
// wird server-seitig auf HTTPS umgeleitet, statt sich allein auf den
// HSTS-Browser-Cache zu verlassen (der erst nach dem ersten sicheren
// Aufruf greift).
func TestMiddleware_RedirectsInsecureRequestToHTTPS(t *testing.T) {
cfg := DefaultConfig()
called := false
handler := Middleware(cfg, func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "http://example.test/pfad?x=1", nil)
// Kein X-Forwarded-Proto, kein r.TLS -> unsicherer Aufruf.
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusMovedPermanently {
t.Fatalf("status = %d, want 301 (redirect auf https)", rec.Code)
}
location := rec.Header().Get("Location")
if !strings.HasPrefix(location, "https://") {
t.Fatalf("location = %q, want https://-praefix", location)
}
if !strings.Contains(location, "/pfad?x=1") {
t.Fatalf("location = %q, erwartet erhaltenen pfad+query", location)
}
if called {
t.Fatal("eigentlicher handler haette bei unsicherem aufruf nicht erreicht werden duerfen")
}
}
+35
View File
@@ -0,0 +1,35 @@
package session
import (
"context"
"fmt"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// LoginAndCreateSession prueft die Anmeldedaten mit demselben timing-safen
// bcrypt-Vergleich wie IAM-02s LoginService (kein zweiter Credential-Check),
// erzeugt bei Erfolg aber eine widerrufbare Sitzung statt eines
// zustandslosen JWT — das ist der Bearer-Token fuer diese Selbstbedienungs-
// Funktion.
func LoginAndCreateSession(ctx context.Context, users *user.TenantUserStore, sessions *Store, email, password, deviceInfo, ip string) (sessionToken string, err error) {
creds, err := users.GetByEmailForAuth(ctx, email)
if err != nil {
return "", fmt.Errorf("anmeldedaten pruefen: %w", err)
}
if creds.User.Status != user.StatusActive {
return "", fmt.Errorf("konto nicht aktiv")
}
// Wiederverwendung von IAM-02s timing-safem bcrypt-Vergleich, kein
// zweiter Passwort-Pruefmechanismus.
if !auth.VerifyPassword(creds.PasswordHash, password) {
return "", fmt.Errorf("anmeldedaten ungueltig")
}
_, sessionToken, err = sessions.Create(ctx, creds.User.ID, deviceInfo, ip)
if err != nil {
return "", fmt.Errorf("sitzung anlegen: %w", err)
}
return sessionToken, nil
}
+142
View File
@@ -0,0 +1,142 @@
// Package session implementiert Core IAM-12: Selbstbedienungs-Sitzungs-
// uebersicht mit Geraete-/Zugriffsinformationen und gezieltem Widerruf.
// Bewusst serverseitig und widerrufbar — ein separater Mechanismus neben
// IAM-02s zustandslosem JWT (das fuer schnelle Modul-zu-Modul-Verifikation
// ohne Core-Rueckfrage gewaehlt wurde, siehe API-05). Fuer den
// Selbstbedienungs-Anwendungsfall hier ist sofortige Widerrufbarkeit
// wichtiger als Zustandslosigkeit.
package session
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrSessionInvalid = errors.New("session: sitzung ungueltig oder widerrufen")
type Session struct {
ID string
UserID string
DeviceInfo string
IP string
CreatedAt time.Time
LastSeenAt time.Time
}
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// Create legt eine neue Sitzung an und liefert den Klartext-Token NUR an den
// Aufrufer zurueck (als Cookie-Wert) — gespeichert wird ausschliesslich der Hash.
func (s *Store) Create(ctx context.Context, userID, deviceInfo, ip string) (id, token string, err error) {
token, err = randomToken()
if err != nil {
return "", "", fmt.Errorf("token erzeugen: %w", err)
}
hash := hashToken(token)
err = s.pool.QueryRow(ctx, `
INSERT INTO sessions (user_id, token_hash, device_info, ip)
VALUES ($1, $2, $3, $4)
RETURNING id
`, userID, hash, deviceInfo, ip).Scan(&id)
if err != nil {
return "", "", fmt.Errorf("sitzung anlegen: %w", err)
}
return id, token, nil
}
// Validate prueft ein Sitzungs-Token direkt gegen die Datenbank — kein
// Cache, ein widerrufenes Token ist beim naechsten Zugriffsversuch sofort
// ungueltig (Akzeptanzkriterium 2). Erfolgreiche Validierung aktualisiert
// last_seen_at (Akzeptanzkriterium 1: letzter Zugriff).
func (s *Store) Validate(ctx context.Context, token string) (Session, error) {
hash := hashToken(token)
var sess Session
err := s.pool.QueryRow(ctx, `
UPDATE sessions SET last_seen_at = now()
WHERE token_hash = $1 AND revoked_at IS NULL
RETURNING id, user_id, device_info, ip, created_at, last_seen_at
`, hash).Scan(&sess.ID, &sess.UserID, &sess.DeviceInfo, &sess.IP, &sess.CreatedAt, &sess.LastSeenAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Session{}, ErrSessionInvalid
}
return Session{}, fmt.Errorf("sitzung pruefen: %w", err)
}
return sess, nil
}
// ListForUser liefert alle NICHT widerrufenen Sitzungen eines Benutzers
// (Akzeptanzkriterium 1).
func (s *Store) ListForUser(ctx context.Context, userID string) ([]Session, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, user_id, device_info, ip, created_at, last_seen_at
FROM sessions WHERE user_id = $1 AND revoked_at IS NULL
ORDER BY last_seen_at DESC
`, userID)
if err != nil {
return nil, fmt.Errorf("sitzungen auflisten: %w", err)
}
defer rows.Close()
var out []Session
for rows.Next() {
var sess Session
if err := rows.Scan(&sess.ID, &sess.UserID, &sess.DeviceInfo, &sess.IP, &sess.CreatedAt, &sess.LastSeenAt); err != nil {
return nil, fmt.Errorf("sitzung lesen: %w", err)
}
out = append(out, sess)
}
return out, rows.Err()
}
// Revoke widerruft eine einzelne Sitzung sofort (Akzeptanzkriterium 2).
func (s *Store) Revoke(ctx context.Context, sessionID string) error {
_, err := s.pool.Exec(ctx, `UPDATE sessions SET revoked_at = now() WHERE id = $1`, sessionID)
if err != nil {
return fmt.Errorf("sitzung widerrufen: %w", err)
}
return nil
}
// RevokeAllExcept beendet alle Sitzungen eines Benutzers ausser der
// angegebenen (Akzeptanzkriterium 3: "alle anderen beenden", aktuelle
// Sitzung bleibt bestehen).
func (s *Store) RevokeAllExcept(ctx context.Context, userID, keepSessionID string) error {
_, err := s.pool.Exec(ctx, `
UPDATE sessions SET revoked_at = now()
WHERE user_id = $1 AND id <> $2 AND revoked_at IS NULL
`, userID, keepSessionID)
if err != nil {
return fmt.Errorf("andere sitzungen widerrufen: %w", err)
}
return nil
}
func randomToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func hashToken(token string) []byte {
sum := sha256.Sum256([]byte(token))
return sum[:]
}
+174
View File
@@ -0,0 +1,174 @@
package session
import (
"context"
"errors"
"fmt"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
func setupTest(t *testing.T) (*Store, *user.TenantUserStore, string, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', password_hash TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id),
token_hash BYTEA NOT NULL UNIQUE, device_info TEXT NOT NULL DEFAULT '', ip TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revoked_at TIMESTAMPTZ
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
userStore := user.NewTenantUserStore(pool)
email := fmt.Sprintf("session-test-%d@example.com", time.Now().UnixNano())
u, err := userStore.Create(ctx, email, "Session Test")
if err != nil {
t.Fatalf("create user: %v", err)
}
cleanup := func() { pool.Close() }
return NewStore(pool), userStore, u.ID, cleanup
}
// Akzeptanzkriterium 1 + Pruefung 1: zwei Sitzungen angelegt, beide in der
// Uebersicht sichtbar.
func TestListForUser_ShowsAllActiveSessions(t *testing.T) {
store, _, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
if _, _, err := store.Create(ctx, userID, "Chrome auf Windows", "10.0.0.1"); err != nil {
t.Fatalf("create session 1: %v", err)
}
if _, _, err := store.Create(ctx, userID, "Safari auf iPhone", "10.0.0.2"); err != nil {
t.Fatalf("create session 2: %v", err)
}
sessions, err := store.ListForUser(ctx, userID)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(sessions) != 2 {
t.Fatalf("erwartet 2 sitzungen, habe %d", len(sessions))
}
}
// Akzeptanzkriterium 2 + Pruefung 2: Widerruf macht das Token sofort ungueltig.
func TestRevoke_InvalidatesTokenImmediately(t *testing.T) {
store, _, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
sessionID, token, err := store.Create(ctx, userID, "Firefox", "10.0.0.3")
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := store.Validate(ctx, token); err != nil {
t.Fatalf("validate vor widerruf: %v", err)
}
if err := store.Revoke(ctx, sessionID); err != nil {
t.Fatalf("revoke: %v", err)
}
if _, err := store.Validate(ctx, token); !errors.Is(err, ErrSessionInvalid) {
t.Fatalf("erwartet ErrSessionInvalid sofort nach widerruf, habe %v", err)
}
}
// Akzeptanzkriterium 3 + Pruefung 3: "alle anderen beenden" funktioniert,
// aktuelle Sitzung bleibt aktiv.
func TestRevokeAllExcept_KeepsCurrentSessionActive(t *testing.T) {
store, _, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
currentID, currentToken, err := store.Create(ctx, userID, "aktuelles geraet", "10.0.0.4")
if err != nil {
t.Fatalf("create current: %v", err)
}
_, otherToken1, err := store.Create(ctx, userID, "anderes geraet 1", "10.0.0.5")
if err != nil {
t.Fatalf("create other 1: %v", err)
}
_, otherToken2, err := store.Create(ctx, userID, "anderes geraet 2", "10.0.0.6")
if err != nil {
t.Fatalf("create other 2: %v", err)
}
if err := store.RevokeAllExcept(ctx, userID, currentID); err != nil {
t.Fatalf("revoke all except: %v", err)
}
if _, err := store.Validate(ctx, currentToken); err != nil {
t.Fatalf("aktuelle sitzung sollte weiterhin gueltig sein: %v", err)
}
if _, err := store.Validate(ctx, otherToken1); !errors.Is(err, ErrSessionInvalid) {
t.Fatalf("andere sitzung 1 sollte widerrufen sein, habe %v", err)
}
if _, err := store.Validate(ctx, otherToken2); !errors.Is(err, ErrSessionInvalid) {
t.Fatalf("andere sitzung 2 sollte widerrufen sein, habe %v", err)
}
remaining, err := store.ListForUser(ctx, userID)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(remaining) != 1 || remaining[0].ID != currentID {
t.Fatalf("erwartet genau die aktuelle sitzung uebrig, habe %+v", remaining)
}
}
func TestLoginAndCreateSession(t *testing.T) {
store, userStore, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
u, err := userStore.Get(ctx, userID)
if err != nil {
t.Fatalf("get user: %v", err)
}
hash, err := auth.HashPassword("korrektes-passwort")
if err != nil {
t.Fatalf("hash: %v", err)
}
if err := userStore.SetPasswordHash(ctx, userID, hash); err != nil {
t.Fatalf("set password: %v", err)
}
token, err := LoginAndCreateSession(ctx, userStore, store, u.Email, "korrektes-passwort", "Test-Client", "127.0.0.1")
if err != nil {
t.Fatalf("login and create session: %v", err)
}
if _, err := store.Validate(ctx, token); err != nil {
t.Fatalf("validate: %v", err)
}
if _, err := LoginAndCreateSession(ctx, userStore, store, u.Email, "falsches-passwort", "Test-Client", "127.0.0.1"); err == nil {
t.Fatal("erwartet fehler bei falschem passwort")
}
}
+1
View File
@@ -0,0 +1 @@
DROP TABLE IF EXISTS sessions;
+18
View File
@@ -0,0 +1,18 @@
-- Aktive-Sitzungen-Verwaltung (IAM-12, siehe core-kanban/tickets/IAM-12.md).
-- Bewusst SERVERSEITIG mit widerrufbarem Zustand — anders als IAM-02s
-- zustandsloses JWT (API-05-Entscheidung fuer Modul-zu-Modul-Vertrauen),
-- braucht die Selbstbedienungs-Sitzungsuebersicht einen Ort, an dem eine
-- einzelne Sitzung sofort und nachweislich ungueltig gemacht werden kann.
-- token_hash enthaelt NIEMALS den Klartext-Token, nur dessen SHA-256-Hash.
CREATE TABLE sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
token_hash BYTEA NOT NULL UNIQUE,
device_info TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revoked_at TIMESTAMPTZ
);
CREATE INDEX sessions_user_idx ON sessions (user_id);
-5
View File
@@ -1,16 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
ROLE="nexarch_test"
export PGPASSWORD="$PASS"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenants CASCADE;"
dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'")
for db in $dbs; do
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS \"${db}\";"
done
echo "Testumgebung zurueckgesetzt: registry-tabelle + $(echo "$dbs" | grep -c . || true) tenant-datenbank(en) entfernt."
-6
View File
@@ -1,18 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
cd "$(dirname "$0")/.."
NEXARCH_TEST_DB_PASSWORD="$PASS" bash scripts/reset-test-env.sh
export TEST_ADMIN_DSN="postgresql://nexarch_test:${PASS}@localhost:5432/postgres?sslmode=disable"
echo "== go build =="
go build ./...
echo "== go vet =="
go vet ./...
echo "== go test (-p 1) =="
go test ./... -p 1 -count=1