diff --git a/CLAUDE.md b/CLAUDE.md index 44733c3..e90e18c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,12 +115,29 @@ reiner Anzeige-Screen reicht nicht): — ein zentraler oder fremder Eintrag ist über diese Route nicht erreichbar (404). -**Weiterhin nicht gebaut:** Anmeldeverfahren-Konfiguration, Rechnungsdaten/ -Abrechnung (kein Abo-System, bewusst "Nicht bauen v1"), Account- -Verwaltung durch den Betreiber (Ebene 5 zeigt Accounts nur lesend an — -Bearbeiten/Sperren hängt an der noch nicht getroffenen Abrechnungs-/ -Freischaltungs-Architektur, siehe Offene Punkte: `account.verified` -wurde beim Produktwechsel sogar entfernt). +**Support-Login (Ebene 5, `betreiber`) ist umgesetzt** — auf +`GET /betreiber/accounts/{id}` kann sich der Betreiber über +`POST .../nutzer/{userID}/anmelden-als` als ein aktiver Kunden-Nutzer +anmelden, ohne dessen Passwort zu kennen (`handleBetreiberLoginAls` in +`betreiber_handlers.go`, `store.CreateImpersonatedSession`, Migration +0014). Nicht für deaktivierte Nutzer möglich. Die neue Sitzung ersetzt +die eigene Betreiber-Sitzung (kein Sitzungs-Stack/"Zurück zum +Betreiber" — der Betreiber meldet sich danach mit den eigenen +Zugangsdaten neu an). Jede Nutzung erzeugt einen `audit_log`-Eintrag +(Action `betreiber_login_als_nutzer`); während der gesamten Sitzung +zeigt die Nav einen auffälligen Banner ("Support-Zugriff durch ...", +siehe `currentImpersonator` in `middleware.go`), damit nie unklar ist, +im Kontext eines fremden Kontos zu handeln. + +**Weiterhin nicht gebaut:** Anmeldeverfahren-Konfiguration, ein echtes +Abo-System (Preismodell und Zahlungsanbieter mit dem Nutzer am +2026-08-30 grundsätzlich geklärt — 3 €/Mitarbeiter/Monat, Mindestabnahme +10 Mitarbeiter, 14 Tage Testphase, Stripe mit SEPA-Lastschrift — die +eigentliche Umsetzung wartet noch auf einen Stripe-Account/Testmodus- +Zugangsdaten), Account-Verwaltung durch den Betreiber jenseits des +Support-Logins (Bearbeiten/Sperren eines Accounts selbst hängt weiter +an der Abrechnungs-/Freischaltungs-Architektur, siehe Offene Punkte: +`account.verified` wurde beim Produktwechsel sogar entfernt). **Mandantenfähigkeit:** jede Tabelle trägt `account_id`. Aktuell wird Isolation in der Anwendungsschicht erzwungen (Handler vergleichen diff --git a/internal/store/auth_test.go b/internal/store/auth_test.go index e32b476..8165fdd 100644 --- a/internal/store/auth_test.go +++ b/internal/store/auth_test.go @@ -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) + } +} diff --git a/internal/store/migrations/0014_session_impersonation.down.sql b/internal/store/migrations/0014_session_impersonation.down.sql new file mode 100644 index 0000000..9136fce --- /dev/null +++ b/internal/store/migrations/0014_session_impersonation.down.sql @@ -0,0 +1 @@ +ALTER TABLE session DROP COLUMN impersonated_by_user_id; diff --git a/internal/store/migrations/0014_session_impersonation.up.sql b/internal/store/migrations/0014_session_impersonation.up.sql new file mode 100644 index 0000000..75e088d --- /dev/null +++ b/internal/store/migrations/0014_session_impersonation.up.sql @@ -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); diff --git a/internal/store/session.go b/internal/store/session.go index 8fed702..71654b9 100644 --- a/internal/store/session.go +++ b/internal/store/session.go @@ -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 } diff --git a/internal/web/auth_handlers.go b/internal/web/auth_handlers.go index e476823..b3801ee 100644 --- a/internal/web/auth_handlers.go +++ b/internal/web/auth_handlers.go @@ -140,6 +140,14 @@ func (s *Server) startSession(w http.ResponseWriter, r *http.Request, userID str if _, err := s.store.CreateSession(r.Context(), token, userID, expiresAt); err != nil { return err } + setSessionCookie(w, r, token, expiresAt) + return nil +} + +// setSessionCookie setzt das Session-Cookie — gemeinsam genutzt von +// startSession (regulärer Login) und handleBetreiberLoginAls +// (Support-Login). +func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresAt time.Time) { http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: token, @@ -149,5 +157,4 @@ func (s *Server) startSession(w http.ResponseWriter, r *http.Request, userID str SameSite: http.SameSiteStrictMode, Expires: expiresAt, }) - return nil } diff --git a/internal/web/betreiber_handlers.go b/internal/web/betreiber_handlers.go index c3e2b1d..55b586c 100644 --- a/internal/web/betreiber_handlers.go +++ b/internal/web/betreiber_handlers.go @@ -2,6 +2,9 @@ package web import ( "net/http" + "time" + + "github.com/netcell-it/deklarix/internal/auth" ) type betreiberDashboardData struct { @@ -73,13 +76,16 @@ func (s *Server) handleBetreiberAccountList(w http.ResponseWriter, r *http.Reque } type betreiberUserView struct { - Email string - Role string + ID string + Email string + Role string + Active bool } type betreiberAccountDetailData struct { Title string Nav navData + AccountID string Name string Users []betreiberUserView CreatedAt string @@ -100,11 +106,11 @@ func (s *Server) handleBetreiberAccountDetail(w http.ResponseWriter, r *http.Req } data := betreiberAccountDetailData{ - Title: "Account", Nav: navFor(r), Name: acc.Name, + Title: "Account", Nav: navFor(r), AccountID: acc.ID, Name: acc.Name, CreatedAt: acc.CreatedAt.Format("02.01.2006 15:04"), } for _, u := range users { - data.Users = append(data.Users, betreiberUserView{Email: u.Email, Role: u.Role}) + data.Users = append(data.Users, betreiberUserView{ID: u.ID, Email: u.Email, Role: u.Role, Active: u.Active}) } if err := s.templates.ExecuteTemplate(w, "betreiber-account-detail", data); err != nil { http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) @@ -143,3 +149,46 @@ func (s *Server) handleBetreiberAuditLog(w http.ResponseWriter, r *http.Request) http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) } } + +// handleBetreiberLoginAls meldet den Betreiber als einen bestimmten +// Kunden-Nutzer an (Support-Login) — ohne dessen Passwort zu kennen. +// Nur für aktive Nutzer möglich (ein deaktivierter Login soll auch per +// Support-Zugriff nicht nutzbar sein). Die neue Sitzung ersetzt die +// eigene Betreiber-Sitzung (kein Sitzungs-Stack) — der Betreiber meldet +// sich danach mit den eigenen Zugangsdaten wieder an, das ist für die +// seltene Nutzung dieser Funktion einfacher als ein "Zurück zum +// Betreiber"-Mechanismus. Jede Nutzung wird append-only im Audit-Log +// festgehalten (Actor = Betreiber, Target = der Kunden-Nutzer) und die +// entstehende Sitzung trägt den Betreiber als ImpersonatedByUserID — +// die Nav zeigt während der gesamten Sitzung einen auffälligen Hinweis +// (siehe currentImpersonator, layout.html), damit nie unklar ist, dass +// gerade im Kontext eines fremden Kontos gehandelt wird. +func (s *Server) handleBetreiberLoginAls(w http.ResponseWriter, r *http.Request) { + betreiber := currentUser(r) + ziel, err := s.store.GetUser(r.Context(), r.PathValue("userID")) + if err != nil || ziel.AccountID != r.PathValue("accountID") { + http.Error(w, "Nutzer nicht gefunden", http.StatusNotFound) + return + } + if !ziel.Active { + http.Error(w, "Nutzer ist deaktiviert", http.StatusBadRequest) + return + } + + token, err := auth.NewSessionToken() + if err != nil { + http.Error(w, "Sitzung konnte nicht erzeugt werden: "+err.Error(), http.StatusInternalServerError) + return + } + expiresAt := time.Now().Add(auth.SessionDuration) + if _, err := s.store.CreateImpersonatedSession(r.Context(), token, ziel.ID, betreiber.ID, expiresAt); err != nil { + http.Error(w, "Sitzung konnte nicht erzeugt werden: "+err.Error(), http.StatusInternalServerError) + return + } + if _, err := s.store.CreateAuditEntry(r.Context(), betreiber.ID, "betreiber_login_als_nutzer", "app_user", ziel.ID, ziel.Email); err != nil { + http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError) + return + } + setSessionCookie(w, r, token, expiresAt) + http.Redirect(w, r, "/", http.StatusSeeOther) +} diff --git a/internal/web/impersonation_test.go b/internal/web/impersonation_test.go new file mode 100644 index 0000000..d925c8d --- /dev/null +++ b/internal/web/impersonation_test.go @@ -0,0 +1,120 @@ +package web_test + +import ( + "context" + "net/http" + "net/url" + "strings" + "testing" +) + +func TestBetreiberKannAlsNutzerAnmelden(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber") + seedAccount(t, fs, "Kunde GmbH", "kunde@example.com") + kunde, err := fs.GetUserByEmail(context.Background(), "kunde@example.com") + if err != nil { + t.Fatalf("GetUserByEmail: %v", err) + } + + resp := postForm(t, s, betreiberCookie, "/betreiber/accounts/"+kunde.AccountID+"/nutzer/"+kunde.ID+"/anmelden-als", url.Values{}) + if resp.Code != http.StatusSeeOther { + t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) + } + cookies := resp.Result().Cookies() + if len(cookies) == 0 { + t.Fatal("expected a new session cookie") + } + neueCookie := cookies[0] + + // Die neue Sitzung ist tatsächlich der Kunden-Login. + protected := getWithCookie(t, s, neueCookie, "/antraege") + if protected.Code != http.StatusOK { + t.Fatalf("expected the impersonated session to work, status = %d", protected.Code) + } +} + +func TestBetreiberLoginAlsZeigtBanner(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber") + seedAccount(t, fs, "Kunde GmbH", "kunde@example.com") + kunde, err := fs.GetUserByEmail(context.Background(), "kunde@example.com") + if err != nil { + t.Fatalf("GetUserByEmail: %v", err) + } + + loginResp := postForm(t, s, betreiberCookie, "/betreiber/accounts/"+kunde.AccountID+"/nutzer/"+kunde.ID+"/anmelden-als", url.Values{}) + neueCookie := loginResp.Result().Cookies()[0] + + resp := getWithCookie(t, s, neueCookie, "/") + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) + } + if !strings.Contains(resp.Body.String(), "Support-Zugriff durch") || !strings.Contains(resp.Body.String(), "betreiber@example.com") { + t.Errorf("expected the impersonation banner naming the betreiber, got: %s", resp.Body.String()) + } +} + +func TestBetreiberLoginAlsProtokolliertAuditEintrag(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber") + seedAccount(t, fs, "Kunde GmbH", "kunde@example.com") + kunde, err := fs.GetUserByEmail(context.Background(), "kunde@example.com") + if err != nil { + t.Fatalf("GetUserByEmail: %v", err) + } + betreiber, err := fs.GetUserByEmail(context.Background(), "betreiber@example.com") + if err != nil { + t.Fatalf("GetUserByEmail: %v", err) + } + + postForm(t, s, betreiberCookie, "/betreiber/accounts/"+kunde.AccountID+"/nutzer/"+kunde.ID+"/anmelden-als", url.Values{}) + + var gefunden bool + for _, e := range fs.auditLog { + if e.Action == "betreiber_login_als_nutzer" && e.ActorUserID == betreiber.ID && e.TargetID == kunde.ID { + gefunden = true + } + } + if !gefunden { + t.Errorf("expected an audit log entry for the impersonation, got: %+v", fs.auditLog) + } +} + +func TestBetreiberCannotLoginAlsDeaktiviertenNutzer(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber") + adminCookie := seedAccountWithRole(t, fs, "Kunde GmbH", "kunde-admin@example.com", "admin") + postForm(t, s, adminCookie, "/verwaltung/nutzer", url.Values{ + "email": {"weg@example.com"}, "password": {"ein-langes-passwort"}, "role": {"mitarbeiter"}, + }) + weg, err := fs.GetUserByEmail(context.Background(), "weg@example.com") + if err != nil { + t.Fatalf("GetUserByEmail: %v", err) + } + postForm(t, s, adminCookie, "/verwaltung/nutzer/"+weg.ID+"/deaktivieren", url.Values{}) + + resp := postForm(t, s, betreiberCookie, "/betreiber/accounts/"+weg.AccountID+"/nutzer/"+weg.ID+"/anmelden-als", url.Values{}) + if resp.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 for a deactivated user", resp.Code) + } +} + +func TestMitarbeiterCannotLoginAls(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter") + self, err := fs.GetUserByEmail(context.Background(), "mitarbeiter@example.com") + if err != nil { + t.Fatalf("GetUserByEmail: %v", err) + } + + resp := postForm(t, s, cookie, "/betreiber/accounts/"+self.AccountID+"/nutzer/"+self.ID+"/anmelden-als", url.Values{}) + if resp.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code) + } +} diff --git a/internal/web/middleware.go b/internal/web/middleware.go index e371088..5c77932 100644 --- a/internal/web/middleware.go +++ b/internal/web/middleware.go @@ -12,44 +12,67 @@ const sessionCookieName = "deklarix_session" type contextKey int -const userContextKey contextKey = iota +const ( + userContextKey contextKey = iota + impersonatorContextKey +) // authenticate liest das Session-Cookie, prüft die Sitzung (existiert, -// nicht abgelaufen) und lädt den zugehörigen Nutzer. Liefert (User{}, false), +// nicht abgelaufen) und lädt den zugehörigen Nutzer. Liefert (User{}, "", false), // wenn irgendein Schritt fehlschlägt — die Gründe (kein Cookie, unbekanntes // Token, abgelaufen, Nutzer weg) werden bewusst nicht unterschieden, damit -// requirePage/requireAPI immer denselben, einzigen Fehlerpfad haben. -func (s *Server) authenticate(r *http.Request) (store.User, bool) { +// requirePage/requireAPI immer denselben, einzigen Fehlerpfad haben. Der +// zweite Rückgabewert ist die E-Mail des Betreibers, falls diese Sitzung +// durch einen Support-Login entstanden ist (siehe CreateImpersonatedSession), +// sonst leer. +func (s *Server) authenticate(r *http.Request) (store.User, string, bool) { cookie, err := r.Cookie(sessionCookieName) if err != nil || cookie.Value == "" { - return store.User{}, false + return store.User{}, "", false } sess, err := s.store.GetSession(r.Context(), cookie.Value) if err != nil { - return store.User{}, false + return store.User{}, "", false } if time.Now().After(sess.ExpiresAt) { - return store.User{}, false + return store.User{}, "", false } user, err := s.store.GetUser(r.Context(), sess.UserID) if err != nil || !user.Active { - return store.User{}, false + return store.User{}, "", false } - return user, true + + impersonator := "" + if sess.ImpersonatedByUserID != nil { + if betreiber, err := s.store.GetUser(r.Context(), *sess.ImpersonatedByUserID); err == nil { + impersonator = betreiber.Email + } + } + return user, impersonator, true +} + +// withAuthContext legt Nutzer und ggf. Impersonator-Hinweis in den +// Request-Kontext — gemeinsame Hilfsfunktion für alle require*-Middlewares. +func withAuthContext(r *http.Request, user store.User, impersonator string) *http.Request { + ctx := context.WithValue(r.Context(), userContextKey, user) + if impersonator != "" { + ctx = context.WithValue(ctx, impersonatorContextKey, impersonator) + } + return r.WithContext(ctx) } // requirePage schützt volle Seitenaufrufe — ohne gültige Sitzung geht // es zurück zu /login (eine echte Navigation, kein htmx-Fragment). func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - user, ok := s.authenticate(r) + user, impersonator, ok := s.authenticate(r) if !ok { http.Redirect(w, r, "/login", http.StatusSeeOther) return } - next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user))) + next(w, withAuthContext(r, user, impersonator)) } } @@ -59,12 +82,12 @@ func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc { // mitten in der Nutzung abgelaufen), daher schlicht 401 statt Redirect. func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - user, ok := s.authenticate(r) + user, impersonator, ok := s.authenticate(r) if !ok { http.Error(w, "nicht angemeldet", http.StatusUnauthorized) return } - next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user))) + next(w, withAuthContext(r, user, impersonator)) } } @@ -76,7 +99,7 @@ func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc { // verraten, dass unter dieser URL überhaupt etwas existiert. func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - user, ok := s.authenticate(r) + user, impersonator, ok := s.authenticate(r) if !ok { http.Redirect(w, r, "/login", http.StatusSeeOther) return @@ -85,7 +108,7 @@ func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc { http.Error(w, "nicht gefunden", http.StatusNotFound) return } - next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user))) + next(w, withAuthContext(r, user, impersonator)) } } @@ -106,7 +129,7 @@ func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc { // nicht erfährt, dass es die Seite überhaupt gibt. func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - user, ok := s.authenticate(r) + user, impersonator, ok := s.authenticate(r) if !ok { http.Redirect(w, r, "/login", http.StatusSeeOther) return @@ -115,7 +138,7 @@ func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc { http.Error(w, "nicht gefunden", http.StatusNotFound) return } - next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user))) + next(w, withAuthContext(r, user, impersonator)) } } @@ -124,7 +147,7 @@ func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc { // requireBetreiber/requireFachebene: 404 statt 403 bei falscher Rolle. func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - user, ok := s.authenticate(r) + user, impersonator, ok := s.authenticate(r) if !ok { http.Redirect(w, r, "/login", http.StatusSeeOther) return @@ -133,7 +156,7 @@ func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc { http.Error(w, "nicht gefunden", http.StatusNotFound) return } - next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user))) + next(w, withAuthContext(r, user, impersonator)) } } @@ -142,9 +165,10 @@ func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc { // currentUser zu geben — die Navigation braucht nur, ob ein // Plattform-Link gezeigt werden soll. type navData struct { - IsBetreiber bool - IsFachebene bool - IsAdmin bool + IsBetreiber bool + IsFachebene bool + IsAdmin bool + Impersonator string // E-Mail des Betreibers bei einem Support-Login, sonst leer } // navFor liefert die Nav-Daten für den angemeldeten Nutzer der Anfrage. @@ -155,8 +179,9 @@ func navFor(r *http.Request) navData { return navData{ IsBetreiber: role == "betreiber", // admin sieht die Fachebene mit, siehe requireFachebene. - IsFachebene: role == "verantwortlicher" || role == "pruefer" || role == "admin", - IsAdmin: role == "admin", + IsFachebene: role == "verantwortlicher" || role == "pruefer" || role == "admin", + IsAdmin: role == "admin", + Impersonator: currentImpersonator(r), } } @@ -171,3 +196,10 @@ func currentUser(r *http.Request) store.User { } return user } + +// currentImpersonator liefert die E-Mail des Betreibers, falls die +// aktuelle Sitzung ein Support-Login ist, sonst einen leeren String. +func currentImpersonator(r *http.Request) string { + v, _ := r.Context().Value(impersonatorContextKey).(string) + return v +} diff --git a/internal/web/server.go b/internal/web/server.go index e8eeb92..0f31ddc 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -39,6 +39,7 @@ type Store interface { ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error) SetUserActive(ctx context.Context, id string, active bool) error CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error) + CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (store.Session, error) GetSession(ctx context.Context, token string) (store.Session, error) DeleteSession(ctx context.Context, token string) error CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error) @@ -152,6 +153,7 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) { mux.HandleFunc("GET /betreiber", s.requireBetreiber(s.handleBetreiberDashboard)) mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList)) mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail)) + mux.HandleFunc("POST /betreiber/accounts/{accountID}/nutzer/{userID}/anmelden-als", s.requireBetreiber(s.handleBetreiberLoginAls)) mux.HandleFunc("GET /betreiber/audit-log", s.requireBetreiber(s.handleBetreiberAuditLog)) mux.HandleFunc("GET /betreiber/werkzeuge", s.requireBetreiber(s.handleBetreiberWerkzeugListe)) mux.HandleFunc("GET /betreiber/werkzeuge/neu", s.requireBetreiber(s.handleBetreiberWerkzeugNeuForm)) diff --git a/internal/web/server_test.go b/internal/web/server_test.go index f8147e6..c4e0192 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -180,6 +180,14 @@ func (f *fakeStore) CreateSession(ctx context.Context, token, userID string, exp return sess, nil } +func (f *fakeStore) CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (store.Session, error) { + f.mu.Lock() + defer f.mu.Unlock() + sess := store.Session{Token: token, UserID: userID, ImpersonatedByUserID: &impersonatedByUserID, ExpiresAt: expiresAt, CreatedAt: time.Now()} + f.sessions[token] = sess + return sess, nil +} + func (f *fakeStore) GetSession(ctx context.Context, token string) (store.Session, error) { f.mu.Lock() defer f.mu.Unlock() diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 4c7e7dc..38be898 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -275,6 +275,38 @@ nav button { margin-top: 24px; } +/* Support-Login-Hinweis (siehe currentImpersonator in middleware.go) — + bewusst auffällig (nicht im normalen .hinweis-Grauton), damit niemand + vergisst, gerade im Kontext eines fremden Kontos zu handeln. */ +.impersonation-banner { + background: var(--color-mittel-bg); + color: var(--color-mittel); + padding: 10px 16px; + font-size: 0.875rem; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + border-bottom: 1px solid var(--color-mittel); +} + +.impersonation-banner form { + margin: 0; +} + +.impersonation-banner button { + margin-top: 0; + min-height: 32px; + padding: 4px 12px; + font-size: 0.8125rem; +} + +@media (min-width: 960px) { + .impersonation-banner { + margin-left: var(--sidebar-width); + } +} + .fehler { background: var(--color-hoch-bg); color: var(--color-hoch); diff --git a/internal/web/templates/betreiber_account_detail.html b/internal/web/templates/betreiber_account_detail.html index a95790d..ee7c806 100644 --- a/internal/web/templates/betreiber_account_detail.html +++ b/internal/web/templates/betreiber_account_detail.html @@ -12,7 +12,15 @@ diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index d6d33bd..53a599e 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -37,6 +37,14 @@ +{{if .Impersonator}} +
+ Support-Zugriff durch {{.Impersonator}} — dies ist nicht Ihre eigene Sitzung. +
+ +
+
+{{end}} {{end}} {{/* bewertung-block rendert eine Bewertung (Datenklasse, KI-VO-