munin/internal/auth/session.go
2026-09-15 20:36:26 +02:00

109 lines
3.5 KiB
Go

package auth
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"fmt"
"strconv"
"strings"
"time"
)
// SessionCookieName ist der Name des Cookies, der die signierte Sitzung trägt.
//
// Wird der Name geändert, gelten alle bestehenden Sitzungen als nicht vorhanden —
// jeder muss sich einmal neu anmelden. Harmlos, aber kein Grund, ihn ohne Anlass
// anzufassen.
const SessionCookieName = "munin_session"
// SessionTTL bestimmt, wie lange eine Sitzung ohne erneute Anmeldung gilt.
const SessionTTL = 30 * 24 * time.Hour
// SubjectPlatformAdmin ist das Session-Subject des Plattform-Admins (ADMIN_TOKEN).
const SubjectPlatformAdmin = "platform"
// SubjectKeyPrefix steht vor der Key-ID im Session-Subject einer normalen Anmeldung.
const SubjectKeyPrefix = "key:"
// Signer signiert und prüft alles, was der Server einem Browser mitgibt:
// Session-Cookies und CSRF-Tokens.
//
// Ein eigener Typ statt loser Funktionen, damit das Geheimnis genau einmal
// übergeben wird und nicht durch jeden Aufruf gereicht werden muss.
type Signer struct {
secret []byte
}
// NewSigner erzeugt einen Signer aus dem SESSION_SECRET.
func NewSigner(secret []byte) *Signer {
return &Signer{secret: secret}
}
// sign berechnet den HMAC-SHA256 einer Nutzlast.
//
// HMAC ist ein Prüfsummenverfahren mit Geheimnis: Wer das Geheimnis nicht kennt,
// kann zu einer Nutzlast keine passende Prüfsumme bilden. Damit kann der Server
// erkennen, ob ein Cookie-Inhalt von ihm selbst stammt oder unterwegs verändert wurde.
func (s *Signer) sign(payload string) string {
mac := hmac.New(sha256.New, s.secret)
mac.Write([]byte(payload))
return hex.EncodeToString(mac.Sum(nil))
}
// SignSession erstellt den Cookie-Wert für ein Subject.
//
// subject ist entweder SubjectPlatformAdmin oder "key:<api-key-id>".
//
// Aufbau: base64("<subject>|<ablauf-unix>") + "." + HMAC
//
// Der Cookie ist lesbar, aber nicht fälschbar. Er enthält bewusst keinen Key —
// wer ihn stiehlt, bekommt eine Sitzung, aber kein dauerhaftes Zugangsmittel für
// die API. Und ob der zugehörige Key überhaupt noch gültig ist, prüft der Server
// bei jedem Request neu in der Datenbank (siehe internal/web).
func (s *Signer) SignSession(subject string) string {
expiry := time.Now().Add(SessionTTL).Unix()
payload := fmt.Sprintf("%s|%d", subject, expiry)
encoded := base64.RawURLEncoding.EncodeToString([]byte(payload))
return encoded + "." + s.sign(payload)
}
// VerifySession prüft Signatur und Ablaufdatum und gibt das Subject zurück.
func (s *Signer) VerifySession(cookieValue string) (subject string, ok bool) {
encoded, signature, found := strings.Cut(cookieValue, ".")
if !found {
return "", false
}
payloadBytes, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return "", false
}
payload := string(payloadBytes)
// Reihenfolge ist wichtig: erst die Signatur prüfen, dann den Inhalt auswerten.
// Alles vor dieser Zeile stammt aus einer nicht vertrauenswürdigen Quelle.
if subtle.ConstantTimeCompare([]byte(signature), []byte(s.sign(payload))) != 1 {
return "", false
}
subject, expiryStr, found := strings.Cut(payload, "|")
if !found {
return "", false
}
expiry, err := strconv.ParseInt(expiryStr, 10, 64)
if err != nil || time.Now().Unix() > expiry {
return "", false
}
return subject, true
}
// KeyIDFromSubject holt die API-Key-ID aus einem Session-Subject.
// Beim Plattform-Admin gibt es keine — dann ist ok false.
func KeyIDFromSubject(subject string) (keyID string, ok bool) {
return strings.CutPrefix(subject, SubjectKeyPrefix)
}