go-wiki/handler/auth.go
2026-07-14 20:46:19 +02:00

181 lines
5.4 KiB
Go

// 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
}
// switchTeamTemplateData bündelt die Daten für das switch-team.html-Template.
type switchTeamTemplateData struct {
Teams []*Team
}
// AuthHandler verwaltet Login, Logout und die Team-Umschaltung für Admins.
type AuthHandler struct {
registry *TeamRegistry
sessionSecret []byte
loginTemplate *template.Template
switchTeamTemplate *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
}
switchTmpl, err := template.ParseFS(templatesFS, "templates/switch-team.html")
if err != nil {
return nil, err
}
return &AuthHandler{
registry: registry,
sessionSecret: sessionSecret,
loginTemplate: loginTmpl,
switchTeamTemplate: switchTmpl,
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 = "/switch-team"
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 zeigt dem Admin eine Team-Auswahl (GET) und setzt das aktive
// Team in der Session (POST). 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 {
h.switchTeamTemplate.Execute(w, switchTeamTemplateData{Teams: h.registry.All()})
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,
})
}