202 lines
7.6 KiB
Go
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)
|
|
}
|