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);