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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user