From 5f02e08bc42c07e1f533a2e634a7838edfe665c7 Mon Sep 17 00:00:00 2001 From: sysops Date: Thu, 27 Aug 2026 22:29:58 +0200 Subject: [PATCH] IAM-12: aktive-sitzungen-verwaltung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/session/login.go | 35 +++++ internal/session/session.go | 142 ++++++++++++++++++ internal/session/session_test.go | 174 +++++++++++++++++++++++ migrations/tenant/0003_sessions.down.sql | 1 + migrations/tenant/0003_sessions.up.sql | 18 +++ scripts/reset-test-env.sh | 11 ++ scripts/run-checks.sh | 12 ++ 7 files changed, 393 insertions(+) create mode 100644 internal/session/login.go create mode 100644 internal/session/session.go create mode 100644 internal/session/session_test.go create mode 100644 migrations/tenant/0003_sessions.down.sql create mode 100644 migrations/tenant/0003_sessions.up.sql create mode 100755 scripts/reset-test-env.sh create mode 100755 scripts/run-checks.sh diff --git a/internal/session/login.go b/internal/session/login.go new file mode 100644 index 0000000..039a351 --- /dev/null +++ b/internal/session/login.go @@ -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 +} diff --git a/internal/session/session.go b/internal/session/session.go new file mode 100644 index 0000000..3f31fc3 --- /dev/null +++ b/internal/session/session.go @@ -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[:] +} diff --git a/internal/session/session_test.go b/internal/session/session_test.go new file mode 100644 index 0000000..b88c1f1 --- /dev/null +++ b/internal/session/session_test.go @@ -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") + } +} diff --git a/migrations/tenant/0003_sessions.down.sql b/migrations/tenant/0003_sessions.down.sql new file mode 100644 index 0000000..63d205d --- /dev/null +++ b/migrations/tenant/0003_sessions.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS sessions; diff --git a/migrations/tenant/0003_sessions.up.sql b/migrations/tenant/0003_sessions.up.sql new file mode 100644 index 0000000..e4b0428 --- /dev/null +++ b/migrations/tenant/0003_sessions.up.sql @@ -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); diff --git a/scripts/reset-test-env.sh b/scripts/reset-test-env.sh new file mode 100755 index 0000000..fab5903 --- /dev/null +++ b/scripts/reset-test-env.sh @@ -0,0 +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." diff --git a/scripts/run-checks.sh b/scripts/run-checks.sh new file mode 100755 index 0000000..1c28c3c --- /dev/null +++ b/scripts/run-checks.sh @@ -0,0 +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