// Dieses File implementiert den Team-Login fürs Web: Formular, Session-Cookie // setzen/löschen, und die Team-Umschaltung für den Admin-Zugang. // // Zu unterscheiden vom bestehenden Seiten-Passwort-Feature (wiki.go): das hier // ist der Zugang zum Wiki insgesamt (welches Team sehe ich?), das Seiten-Passwort // schützt zusätzlich einzelne Seiten innerhalb eines Teams. package handler import ( "fmt" "html/template" "io/fs" "net" "net/http" "strconv" "time" ) // loginTemplateData bündelt die Daten für das login.html-Template. type loginTemplateData struct { Error string } // AuthHandler verwaltet Login, Logout und die Team-Umschaltung für Admins. type AuthHandler struct { registry *TeamRegistry sessionSecret []byte loginTemplate *template.Template rateLimiter *LoginRateLimiter audit *AuditLogger } // NewAuthHandler lädt die Templates und erstellt einen neuen AuthHandler. func NewAuthHandler(templatesFS fs.FS, registry *TeamRegistry, sessionSecret []byte, audit *AuditLogger) (*AuthHandler, error) { loginTmpl, err := template.ParseFS(templatesFS, "templates/login.html") if err != nil { return nil, err } return &AuthHandler{ registry: registry, sessionSecret: sessionSecret, loginTemplate: loginTmpl, rateLimiter: NewLoginRateLimiter(), audit: audit, }, nil } // HandleLogin verarbeitet GET /login (Formular anzeigen) und POST /login (Key prüfen). func (h *AuthHandler) HandleLogin(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { h.loginTemplate.Execute(w, loginTemplateData{}) return } if r.Method != http.MethodPost { http.Error(w, "Nur GET/POST erlaubt", http.StatusMethodNotAllowed) return } ip := clientIP(r) // Rate-Limiting: verhindert dass ein Angreifer beliebig oft Keys durchprobiert. if allowed, retryAfter := h.rateLimiter.Allow(ip); !allowed { seconds := int(retryAfter.Seconds()) + 1 w.Header().Set("Retry-After", strconv.Itoa(seconds)) http.Error(w, fmt.Sprintf("Zu viele Login-Versuche. Bitte in %d Sekunden erneut versuchen.", seconds), http.StatusTooManyRequests) return } key := r.FormValue("key") team, isAdmin, ok := h.registry.Resolve(key) if !ok { h.rateLimiter.RecordFailure(ip) h.audit.Log(AuditEvent{Actor: "unbekannt", Action: "login_failure", Source: "web", IP: ip}) h.loginTemplate.Execute(w, loginTemplateData{Error: "Ungültiger Key."}) return } // Erfolgreicher Login → Zähler dieser IP zurücksetzen. h.rateLimiter.Reset(ip) var subject string var redirectTo string var teamID string actor := "team" if isAdmin { subject = "admin" redirectTo = "/admin/teams" actor = "admin" } else { subject = "team:" + team.ID redirectTo = "/" teamID = team.ID } h.audit.Log(AuditEvent{Team: teamID, Actor: actor, Action: "login_success", Source: "web", IP: ip}) h.setSessionCookie(w, subject) http.Redirect(w, r, redirectTo, http.StatusSeeOther) } // clientIP extrahiert die Client-IP aus r.RemoteAddr (Format "ip:port"). // // Hinweis: Läuft der Server hinter einem Reverse-Proxy (nginx, Cloudflare, ...), // ist r.RemoteAddr die IP des Proxys, nicht die des eigentlichen Clients — dann // müsste stattdessen ein vom Proxy gesetzter Header wie X-Forwarded-For ausgewertet // werden. Das ist hier bewusst weggelassen: dieser Header ist ohne eine bekannte, // vertrauenswürdige Proxy-Kette leicht zu fälschen und würde das Rate-Limiting aushebeln. func clientIP(r *http.Request) string { host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr } return host } // HandleLogout löscht den Session-Cookie und schickt zurück zum Login-Formular. func (h *AuthHandler) HandleLogout(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{ Name: SessionCookieName, Value: "", Path: "/", Expires: time.Unix(0, 0), MaxAge: -1, HttpOnly: true, }) http.Redirect(w, r, "/login", http.StatusSeeOther) } // HandleSwitchTeam aktiviert für den Admin ein Team in der Session (POST) und // springt bei einem versehentlichen GET zur Team-Verwaltung, die die eigentliche // Team-Auswahl übernimmt (siehe TeamsAdminHandler und templates/teams-admin.html — // dort hat jedes Team einen "Öffnen"-Button, der genau dieses POST auslöst). // Nur erreichbar mit einer Admin-Session. func (h *AuthHandler) HandleSwitchTeam(w http.ResponseWriter, r *http.Request) { if !IsAdminFromContext(r.Context()) { http.Redirect(w, r, "/", http.StatusSeeOther) return } if r.Method == http.MethodGet { http.Redirect(w, r, "/admin/teams", http.StatusSeeOther) return } if r.Method != http.MethodPost { http.Error(w, "Nur GET/POST erlaubt", http.StatusMethodNotAllowed) return } teamID := r.FormValue("team") if _, ok := h.registry.ByID(teamID); !ok { http.Error(w, "Unbekanntes Team", http.StatusBadRequest) return } h.setSessionCookie(w, "admin:"+teamID) http.Redirect(w, r, "/", http.StatusSeeOther) } // setSessionCookie signiert das subject und setzt es als Session-Cookie. func (h *AuthHandler) setSessionCookie(w http.ResponseWriter, subject string) { http.SetCookie(w, &http.Cookie{ Name: SessionCookieName, Value: SignSession(subject, h.sessionSecret), Path: "/", Expires: time.Now().Add(SessionTTL), HttpOnly: true, }) }