package store import ( "context" "errors" "fmt" "time" "github.com/jackc/pgx/v5" ) // Session ist eine angemeldete Sitzung. token ist der Primärschlüssel // (das Cookie-Geheimnis selbst) — es gibt bewusst keine separate ID, // eine Session wird immer über ihren Token nachgeschlagen. // ImpersonatedByUserID ist gesetzt, wenn diese Sitzung durch einen // Betreiber-Support-Login entstanden ist (siehe CreateImpersonatedSession) // statt durch den regulären Login des Nutzers selbst. type Session struct { Token string UserID string ImpersonatedByUserID *string ExpiresAt time.Time CreatedAt time.Time } const sessionColumns = `token, user_id, impersonated_by_user_id, expires_at, created_at` func scanSession(row interface { Scan(dest ...any) error }) (Session, error) { var sess Session err := row.Scan(&sess.Token, &sess.UserID, &sess.ImpersonatedByUserID, &sess.ExpiresAt, &sess.CreatedAt) return sess, err } // CreateSession speichert eine neue Sitzung. token muss bereits ein // kryptographisch zufälliges Geheimnis sein (siehe internal/auth). func (s *Store) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (Session, error) { row := s.Pool.QueryRow(ctx, ` INSERT INTO session (token, user_id, expires_at) VALUES ($1, $2, $3) RETURNING `+sessionColumns, token, userID, expiresAt, ) sess, err := scanSession(row) if err != nil { return Session{}, fmt.Errorf("store: create session: %w", err) } return sess, nil } // CreateImpersonatedSession speichert eine Support-Login-Sitzung: ein // Betreiber meldet sich als ein bestimmter Kunden-Nutzer an, ohne // dessen Passwort zu kennen (siehe handleBetreiberLoginAls). Anders als // bei CreateSession bleibt hier festgehalten, WER die Sitzung ausgelöst // hat — für den sichtbaren Hinweis-Banner und das Audit-Log. func (s *Store) CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (Session, error) { row := s.Pool.QueryRow(ctx, ` INSERT INTO session (token, user_id, impersonated_by_user_id, expires_at) VALUES ($1, $2, $3, $4) RETURNING `+sessionColumns, token, userID, impersonatedByUserID, expiresAt, ) sess, err := scanSession(row) if err != nil { return Session{}, fmt.Errorf("store: create impersonated session: %w", err) } return sess, nil } // GetSession liest eine Sitzung anhand ihres Tokens. Liefert // ErrNotFound, wenn der Token unbekannt ist — abgelaufene Sitzungen // werden NICHT automatisch als "nicht gefunden" behandelt, das prüft // der Aufrufer über ExpiresAt (siehe internal/auth), damit die // Unterscheidung "gab es nie" vs. "ist abgelaufen" nicht verloren geht. func (s *Store) GetSession(ctx context.Context, token string) (Session, error) { row := s.Pool.QueryRow(ctx, `SELECT `+sessionColumns+` FROM session WHERE token = $1`, token) sess, err := scanSession(row) if errors.Is(err, pgx.ErrNoRows) { return Session{}, ErrNotFound } if err != nil { return Session{}, fmt.Errorf("store: get session: %w", err) } return sess, nil } // DeleteSession beendet eine Sitzung (Logout). func (s *Store) DeleteSession(ctx context.Context, token string) error { if _, err := s.Pool.Exec(ctx, `DELETE FROM session WHERE token = $1`, token); err != nil { return fmt.Errorf("store: delete session: %w", err) } return nil }