feat: Support-Login — Betreiber kann sich als Kunden-Nutzer anmelden
Ebene 5 (Betreiber) kann sich jetzt auf der Account-Detailseite über
einen Button je Nutzer als dieser Kunden-Login anmelden, ohne dessen
Passwort zu kennen — für Support-Fälle, in denen der Betreiber
nachvollziehen muss, was ein Kunde sieht. Nicht für deaktivierte
Nutzer möglich. Neue Spalte session.impersonated_by_user_id (Migration
0014, store.CreateImpersonatedSession) hält fest, wer die Sitzung
ausgelöst hat — die Nav zeigt während der gesamten Sitzung einen
auffälligen Banner ("Support-Zugriff durch ..."), damit nie unklar
ist, im Kontext eines fremden Kontos zu handeln. Jede Nutzung erzeugt
einen audit_log-Eintrag. Die neue Sitzung ersetzt die eigene
Betreiber-Sitzung (kein Sitzungs-Stack) — nach der Nutzung meldet sich
der Betreiber mit den eigenen Zugangsdaten neu an.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -191,6 +191,9 @@ func TestSessionCRUD(t *testing.T) {
|
||||
if got.UserID != user.ID {
|
||||
t.Fatalf("UserID = %q, want %q", got.UserID, user.ID)
|
||||
}
|
||||
if got.ImpersonatedByUserID != nil {
|
||||
t.Errorf("ImpersonatedByUserID = %v, want nil for a regular session", got.ImpersonatedByUserID)
|
||||
}
|
||||
|
||||
if err := s.DeleteSession(ctx, sess.Token); err != nil {
|
||||
t.Fatalf("DeleteSession: %v", err)
|
||||
@@ -207,3 +210,38 @@ func TestGetSessionNotFound(t *testing.T) {
|
||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateImpersonatedSession(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
kundenAcc := testAccountID(t, s)
|
||||
kundenNutzer, err := s.CreateUser(ctx, kundenAcc, "kunde@example.com", "hash", "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser (Kunde): %v", err)
|
||||
}
|
||||
betreiberAcc := testAccountID(t, s)
|
||||
betreiber, err := s.CreateUser(ctx, betreiberAcc, "betreiber@example.com", "hash", "betreiber")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser (Betreiber): %v", err)
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(time.Hour).Truncate(time.Millisecond)
|
||||
sess, err := s.CreateImpersonatedSession(ctx, "support-token-123", kundenNutzer.ID, betreiber.ID, expiresAt)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateImpersonatedSession: %v", err)
|
||||
}
|
||||
if sess.UserID != kundenNutzer.ID {
|
||||
t.Errorf("UserID = %q, want %q", sess.UserID, kundenNutzer.ID)
|
||||
}
|
||||
if sess.ImpersonatedByUserID == nil || *sess.ImpersonatedByUserID != betreiber.ID {
|
||||
t.Fatalf("ImpersonatedByUserID = %v, want %q", sess.ImpersonatedByUserID, betreiber.ID)
|
||||
}
|
||||
|
||||
got, err := s.GetSession(ctx, sess.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession: %v", err)
|
||||
}
|
||||
if got.ImpersonatedByUserID == nil || *got.ImpersonatedByUserID != betreiber.ID {
|
||||
t.Fatalf("GetSession ImpersonatedByUserID = %v, want %q", got.ImpersonatedByUserID, betreiber.ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE session DROP COLUMN impersonated_by_user_id;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Support-Login (Betreiber meldet sich als Kunden-Nutzer an, siehe
|
||||
-- internal/web/betreiber_handlers.go). impersonated_by_user_id ist
|
||||
-- gesetzt, wenn diese Sitzung durch einen Betreiber-Support-Login
|
||||
-- entstanden ist (nicht durch den regulären Login des Nutzers selbst)
|
||||
-- — die authenticate-Middleware liest das, um in der Nav einen
|
||||
-- deutlichen Hinweis-Banner anzuzeigen, damit niemand vergisst, dass
|
||||
-- er/sie gerade im Kontext eines fremden Kontos handelt.
|
||||
ALTER TABLE session ADD COLUMN impersonated_by_user_id UUID REFERENCES app_user (id);
|
||||
@@ -12,38 +12,70 @@ import (
|
||||
// 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
|
||||
ExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
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) {
|
||||
var sess Session
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO session (token, user_id, expires_at)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING token, user_id, expires_at, created_at
|
||||
`, token, userID, expiresAt).Scan(&sess.Token, &sess.UserID, &sess.ExpiresAt, &sess.CreatedAt)
|
||||
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) {
|
||||
var sess Session
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT token, user_id, expires_at, created_at FROM session WHERE token = $1
|
||||
`, token).Scan(&sess.Token, &sess.UserID, &sess.ExpiresAt, &sess.CreatedAt)
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user