172 lines
5.2 KiB
Go
172 lines
5.2 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
|
|
}
|
|
|
|
// 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,
|
|
})
|
|
}
|