munin/internal/auth/session.go

162 lines
5.9 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 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).
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
}
// 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)
}