package web import ( "fmt" "log" "net/http" "strconv" "time" "munin/internal/auth" "munin/internal/store" ) // handleHome leitet nach der Anmeldung an die passende Startseite weiter. func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { sess, _ := SessionFrom(r) if sess.IsWorkspaceAdmin { http.Redirect(w, r, "/admin/projects", http.StatusSeeOther) return } if sess.IsPlatformAdmin { if sess.WorkspaceID == "" { http.Redirect(w, r, "/admin/workspaces", http.StatusSeeOther) return } http.Redirect(w, r, "/admin/projects", http.StatusSeeOther) return } http.Redirect(w, r, "/p/"+sess.Project.Slug+"/", http.StatusSeeOther) } // handleLoginForm zeigt das Anmeldeformular. func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) { s.render(w, r, "login.html", http.StatusOK, pageData{Title: "Anmelden"}) } // handleLoginSubmit prüft den eingegebenen Key und setzt den Session-Cookie. func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) { ip := auth.ClientIP(r) key := r.FormValue("key") // Die Sperre zuerst prüfen, erst danach den Key. Andersherum bekäme eine // gesperrte IP beim richtigen Key trotzdem eine Sitzung — die Sperre // änderte dann nur den Statuscode, bremste das Raten aber nicht. Das zählt // vor allem für ADMIN_TOKEN, das (anders als die zufälligen Keys) von Hand // gewählt sein kann. Der Preis: Wer sich fünfmal vertippt, wartet eine // Minute, auch mit dem richtigen Key. if allowed, retryAfter := s.limiter.Allow(ip); !allowed { seconds := int(retryAfter.Seconds()) + 1 w.Header().Set("Retry-After", strconv.Itoa(seconds)) http.Error(w, fmt.Sprintf("Zu viele Anmeldeversuche. Bitte in %d Sekunden erneut versuchen.", seconds), http.StatusTooManyRequests) return } subject, ok := s.resolveLoginKey(r, key) if !ok { s.limiter.RecordFailure(ip) // Ein ungültiger Key oder Klartext lässt sich keinem Workspace zuordnen // — siehe der entsprechende Kommentar in APIAuth (middleware.go). log.Printf("Ungültiger Anmeldeversuch von %s", ip) s.render(w, r, "login.html", http.StatusUnauthorized, pageData{ Title: "Anmelden", Error: "Ungültiger Key.", }) return } // Bewusst kein limiter.Reset: Sonst könnte jemand mit einem eigenen, // gültigen Key den Zähler nach jedem vierten Fehlversuch leeren und // beliebig lange weiterraten. Die Fehlversuche verfallen nach einer Minute // von selbst. s.setSessionCookie(w, r, subject, time.Now().Add(auth.SessionTTLFor(subject))) http.Redirect(w, r, "/", http.StatusSeeOther) } // resolveLoginKey prüft einen Key beim Anmelden und gibt das Session-Subject zurück. // // Der Plattform-Admin bekommt hier noch keinen Workspace zugewiesen — das // Subject enthält zunächst nur den Fingerabdruck des ADMIN_TOKEN. Erst über /admin/workspaces wird ein // Workspace gewählt und das Subject um dessen ID ergänzt (siehe // handleWorkspaceSelect). func (s *Server) resolveLoginKey(r *http.Request, key string) (string, bool) { if key == "" { return "", false } // Der Plattform-Admin meldet sich mit dem ADMIN_TOKEN an. Verglichen werden // die Hashes, damit auch hier keine Zeichenkette Zeichen für Zeichen // verglichen wird und die Antwortzeit nichts verrät. if auth.HashKey(key) == s.adminTokenHash { log.Printf("Anmeldung Plattform-Admin von %s", auth.ClientIP(r)) return s.platformSubject(""), true } if auth.LooksLikeWorkspaceAdminToken(key) { return s.resolveWorkspaceAdminLogin(r, key) } if !auth.LooksLikeKey(key) { return "", false } hash := auth.HashKey(key) workspaceID, err := s.control.ResolveKeyWorkspace(r.Context(), hash) if err != nil { return "", false } ws, err := s.workspaces.Get(workspaceID) if err != nil { return "", false } resolved, err := ws.Store.ResolveKeyHash(r.Context(), hash) if err != nil { return "", false } ws.Store.LogAudit(r.Context(), store.AuditEvent{ ProjectID: resolved.Project.ID, APIKeyID: resolved.Key.ID, Actor: resolved.Key.Name, Action: store.ActionLoginSuccess, Source: store.SourceWeb, IP: auth.ClientIP(r), }) return auth.SubjectKeyPrefix + workspaceID + ":" + resolved.Key.ID, true } // resolveWorkspaceAdminLogin prüft das Token eines Workspace-Admins und gibt // sein Session-Subject zurück. // // Die Anmeldung landet im Protokoll des Workspace, ohne Projekt — sie betrifft // keines im Besonderen. Sichtbar ist sie im Protokoll aller Projekte // (/admin/audit). func (s *Server) resolveWorkspaceAdminLogin(r *http.Request, token string) (string, bool) { admin, err := s.control.ResolveWorkspaceAdminHash(r.Context(), auth.HashKey(token)) if err != nil { return "", false } ws, err := s.workspaces.Get(admin.WorkspaceID) if err != nil { return "", false } ws.Store.LogAudit(r.Context(), store.AuditEvent{ Actor: Session{IsWorkspaceAdmin: true, WorkspaceAdmin: admin}.ActorName(), Action: store.ActionLoginSuccess, Source: store.SourceWeb, IP: auth.ClientIP(r), }) return auth.SubjectWorkspaceAdminPrefix + admin.WorkspaceID + ":" + admin.ID, true } // handleLogout löscht den Session-Cookie. func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { cookie := &http.Cookie{ Name: auth.SessionCookieName, Value: "", Expires: time.Unix(0, 0), MaxAge: -1, } // Ein Lösch-Cookie muss dieselben Merkmale tragen wie das Original, sonst // behandelt der Browser es als ein anderes Cookie und das alte bleibt liegen. s.cookies.Apply(cookie, r) http.SetCookie(w, cookie) http.Redirect(w, r, "/login", http.StatusSeeOther) } // platformSubject baut das Session-Subject des Plattform-Admins mit dem // Fingerabdruck des aktuellen ADMIN_TOKEN. workspaceID darf leer sein. func (s *Server) platformSubject(workspaceID string) string { return auth.PlatformSubject(s.adminFingerprint, workspaceID) } // setSessionCookie signiert das Subject und setzt es als Cookie, gültig bis // expiry. // // Bei einer Anmeldung ist expiry jetzt plus auth.SessionTTLFor. Wechselt der // Plattform-Admin nur den Workspace, wird der Ablauf der laufenden Sitzung // übernommen (Session.Expires). Sonst verlängerte jeder Wechsel die Sitzung — // und wer einen Cookie gestohlen hat, könnte ihn durch Umschalten ewig am // Leben halten. func (s *Server) setSessionCookie(w http.ResponseWriter, r *http.Request, subject string, expiry time.Time) { cookie := &http.Cookie{ Name: auth.SessionCookieName, Value: s.signer.SignSession(subject, expiry), Expires: expiry, } s.cookies.Apply(cookie, r) http.SetCookie(w, cookie) }