munin/internal/auth/session.go
2026-10-19 10:13:56 +02:00

202 lines
7.6 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.
//
// 30 Tage sind vertretbar, weil sich diese Sitzungen jederzeit beenden lassen:
// Bei Key und Workspace-Admin prüft der Server bei jedem Request in der
// Datenbank, ob der Zugang noch gilt — ein Widerruf wirkt sofort, auch auf
// einen gestohlenen Cookie.
const SessionTTL = 30 * 24 * time.Hour
// PlatformAdminSessionTTL gilt für die Sitzung des Plattform-Admins.
//
// Kürzer, weil es hier keinen Widerruf in der Datenbank gibt: Ein gestohlener
// Cookie bliebe sonst bis zum Ablauf gültig — oder bis jemand das ADMIN_TOKEN
// tauscht. Und der Plattform-Admin kommt an alles.
const PlatformAdminSessionTTL = 12 * time.Hour
// SessionTTLFor gibt die Laufzeit zurück, die eine Sitzung für dieses Subject
// höchstens haben darf.
func SessionTTLFor(subject string) time.Duration {
if strings.HasPrefix(subject, SubjectPlatformAdmin+"/") {
return PlatformAdminSessionTTL
}
return SessionTTL
}
// SubjectPlatformAdmin ist der Anfang des Session-Subjects des Plattform-Admins
// (ADMIN_TOKEN). Das vollständige Subject baut PlatformSubject.
const SubjectPlatformAdmin = "platform"
// SubjectKeyPrefix steht vor der Key-ID im Session-Subject einer normalen Anmeldung.
const SubjectKeyPrefix = "key:"
// SubjectWorkspaceAdminPrefix steht vor "<workspace-id>:<zugangs-id>" im
// Session-Subject eines Workspace-Admins.
const SubjectWorkspaceAdminPrefix = "wsadmin:"
// 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 stammt aus PlatformSubject oder beginnt mit SubjectKeyPrefix bzw.
// SubjectWorkspaceAdminPrefix.
//
// 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).
//
// expiry ist der Ablaufzeitpunkt. Bei einer Anmeldung ist das jetzt plus
// SessionTTLFor(subject); wechselt der Plattform-Admin nur den Workspace,
// bleibt der bisherige Zeitpunkt (siehe setSessionCookie in internal/web).
func (s *Signer) SignSession(subject string, expiry time.Time) string {
payload := fmt.Sprintf("%s|%d", subject, expiry.Unix())
encoded := base64.RawURLEncoding.EncodeToString([]byte(payload))
return encoded + "." + s.sign(payload)
}
// VerifySession prüft Signatur und Ablaufdatum und gibt Subject und
// Ablaufzeitpunkt zurück.
func (s *Signer) VerifySession(cookieValue string) (subject string, expiry time.Time, ok bool) {
encoded, signature, found := strings.Cut(cookieValue, ".")
if !found {
return "", time.Time{}, false
}
payloadBytes, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return "", time.Time{}, 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 "", time.Time{}, false
}
subject, expiryStr, found := strings.Cut(payload, "|")
if !found {
return "", time.Time{}, false
}
unix, err := strconv.ParseInt(expiryStr, 10, 64)
if err != nil {
return "", time.Time{}, false
}
expiry = time.Unix(unix, 0)
now := time.Now()
if now.After(expiry) {
return "", time.Time{}, false
}
// Eine Sitzung, die länger gilt, als für ihre Art erlaubt ist, wird
// abgelehnt. Das erwischt Plattform-Admin-Cookies, die noch mit den
// früheren 30 Tagen ausgestellt wurden — die enden damit sofort, statt
// noch wochenlang zu gelten. (Eine Minute Spielraum für Uhren, die nicht
// ganz gleich gehen.)
if expiry.Sub(now) > SessionTTLFor(subject)+time.Minute {
return "", time.Time{}, false
}
return subject, expiry, true
}
// Fingerprint leitet aus einem Wert einen kurzen, nicht umkehrbaren
// Fingerabdruck ab, der nur mit dem SESSION_SECRET zu berechnen ist.
//
// Gebraucht wird er für die Sitzung des Plattform-Admins (siehe
// PlatformSubject). Der Cookie ist lesbar, deshalb steht darin nicht der Hash
// des ADMIN_TOKEN selbst, sondern nur dieser Abdruck davon.
func (s *Signer) Fingerprint(value string) string {
return s.sign("fingerabdruck|" + value)[:16]
}
// PlatformSubject baut das Session-Subject des Plattform-Admins:
//
// "platform/<fingerabdruck>" noch kein Workspace gewählt
// "platform/<fingerabdruck>:<workspace-id>" mit gewähltem Workspace
//
// # Warum ein Fingerabdruck des ADMIN_TOKEN im Subject?
//
// Bei Keys und Workspace-Admins prüft der Server bei jedem Request in der
// Datenbank, ob der Zugang noch gilt. Beim Plattform-Admin gibt es keinen
// Datensatz, nur die Umgebungsvariable. Früher stand im Subject einfach
// "platform", und eine Sitzung blieb gültig, solange die Signatur stimmte.
// Wer sich mit einem geleakten ADMIN_TOKEN angemeldet hatte, blieb deshalb
// bis zu 30 Tage angemeldet, auch nachdem das Token längst ersetzt war.
//
// Mit dem Fingerabdruck vergleicht der Server bei jedem Request, ob die Sitzung
// mit dem *aktuellen* ADMIN_TOKEN entstanden ist. Ein neues Token beendet so
// sofort alle Admin-Sitzungen, während Teams mit ihren Keys angemeldet bleiben.
func PlatformSubject(fingerprint, workspaceID string) string {
subject := SubjectPlatformAdmin + "/" + fingerprint
if workspaceID != "" {
subject += ":" + workspaceID
}
return subject
}
// ParsePlatformSubject zerlegt ein Subject aus PlatformSubject. ok ist false,
// wenn es kein Subject des Plattform-Admins ist, auch bei der alten Form ohne
// Fingerabdruck. workspaceID ist leer, solange kein Workspace gewählt ist.
func ParsePlatformSubject(subject string) (fingerprint, workspaceID string, ok bool) {
rest, ok := strings.CutPrefix(subject, SubjectPlatformAdmin+"/")
if !ok || rest == "" {
return "", "", false
}
fingerprint, workspaceID, _ = strings.Cut(rest, ":")
return fingerprint, workspaceID, fingerprint != ""
}
// 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)
}