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 ":" 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("|") + "." + 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/" noch kein Workspace gewählt // "platform/:" 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) }