// Package auth beantwortet eine einzige Frage: Wer fragt da? // // Drei Antworten sind möglich — der eigene OIDC-Anbieter, ein vorgelagerter // Proxy, oder niemand (offener Betrieb). package auth import ( "honeyos/internal/config" "honeyos/internal/store" "context" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" "errors" "fmt" "log" "net/http" "strconv" "strings" "sync" "time" gooidc "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/oauth2" ) // Anmeldung: HoneyOS führt keine Nutzerverwaltung. Wer beim Anbieter durchkommt, // ist drin — mit einer optionalen Namensliste als zweitem Riegel. Die Sitzung // ist ein signiertes Cookie ohne Gegenstück auf dem Server: nichts zu putzen, // nichts zu verlieren, ein Neustart wirft niemanden hinaus. const ( // Exportiert, weil die Server-Tests im Wurzelpaket dieselben Namen brauchen // — sonst stünden die Zeichenketten zweimal da und liefen auseinander. SessionCookie = "honeyos_session" CSRFCookie = "honeyos_csrf" StateCookie = "honeyos_state" sessionTTL = 30 * 24 * time.Hour ) type Identity struct { Sub string Name string Picture string } type Auth struct { cfg config.Config secret []byte once sync.Once initErr error provider *gooidc.Provider oauth *oauth2.Config verifier *gooidc.IDTokenVerifier } func New(cfg config.Config) *Auth { return &Auth{cfg: cfg, secret: []byte(cfg.SessionSecret)} } func (a *Auth) Enabled() bool { return a.cfg.OIDCIssuer != "" } // setup holt die Anbieterdaten beim ersten Bedarf statt beim Start. Ein // Anbieter, der gerade neu startet, soll HoneyOS nicht mit hinunterreißen; // beim nächsten Versuch geht es weiter. func (a *Auth) setup(ctx context.Context) error { a.once.Do(func() { p, err := gooidc.NewProvider(ctx, a.cfg.OIDCIssuer) if err != nil { a.initErr = fmt.Errorf("OIDC-Anbieter %s: %w", a.cfg.OIDCIssuer, err) return } a.provider = p a.verifier = p.Verifier(&gooidc.Config{ClientID: a.cfg.OIDCClientID}) a.oauth = &oauth2.Config{ ClientID: a.cfg.OIDCClientID, ClientSecret: a.cfg.OIDCClientSecret, RedirectURL: a.cfg.RedirectURL(), Endpoint: p.Endpoint(), Scopes: []string{gooidc.ScopeOpenID, "profile"}, } }) if a.initErr != nil { // Nächster Aufruf darf es erneut versuchen. a.once = sync.Once{} err := a.initErr a.initErr = nil return err } return nil } /* ── Sitzungscookie ──────────────────────────────────────────────────────── */ func (a *Auth) sign(payload string) string { m := hmac.New(sha256.New, a.secret) m.Write([]byte(payload)) return base64.RawURLEncoding.EncodeToString(m.Sum(nil)) } func (a *Auth) issueSession(w http.ResponseWriter, r *http.Request, sub string) { payload := base64.RawURLEncoding.EncodeToString([]byte(sub)) + "." + strconv.FormatInt(time.Now().Add(sessionTTL).Unix(), 10) setCookie(w, r, SessionCookie, payload+"."+a.sign(payload), int(sessionTTL.Seconds()), true) // Zweites Cookie, absichtlich lesbar: der Browser schickt es als Kopfzeile // zurück, und nur wer die Seite wirklich geöffnet hat, kann das. tok := randomString(24) setCookie(w, r, CSRFCookie, tok, int(sessionTTL.Seconds()), false) } func (a *Auth) ClearSession(w http.ResponseWriter, r *http.Request) { setCookie(w, r, SessionCookie, "", -1, true) setCookie(w, r, CSRFCookie, "", -1, false) } // SubFromRequest liest die Sitzung. Zweiter Rückgabewert sagt, ob sie gilt. // // Ohne eigene Anmeldung (Proxy davor oder offener Betrieb) gilt jede Anfrage // als angemeldet — die Prüfung hat dann jemand anders gemacht, oder es gibt // keine. Nennt der Proxy einen Namen, bekommt der seine eigenen Einstellungen. func (a *Auth) SubFromRequest(r *http.Request) (string, bool) { if !a.Enabled() { if a.cfg.UserHeader != "" { if u := strings.TrimSpace(r.Header.Get(a.cfg.UserHeader)); u != "" { return "proxy:" + u, true } } return store.LocalSub, true } c, err := r.Cookie(SessionCookie) if err != nil { return "", false } parts := strings.Split(c.Value, ".") if len(parts) != 3 { return "", false } payload := parts[0] + "." + parts[1] if !hmac.Equal([]byte(a.sign(payload)), []byte(parts[2])) { return "", false } exp, err := strconv.ParseInt(parts[1], 10, 64) if err != nil || time.Now().Unix() > exp { return "", false } sub, err := base64.RawURLEncoding.DecodeString(parts[0]) if err != nil { return "", false } return string(sub), true } // CheckCSRF prüft das doppelt gesendete Prüfwort. Nötig bei allem, was schreibt. func (a *Auth) CheckCSRF(r *http.Request) error { if !a.Enabled() { return nil } c, err := r.Cookie(CSRFCookie) if err != nil || c.Value == "" { return errors.New("kein CSRF-Cookie") } if r.Header.Get("X-CSRF-Token") != c.Value { return errors.New("CSRF-Kopfzeile passt nicht") } return nil } /* ── Ablauf ──────────────────────────────────────────────────────────────── */ func (a *Auth) Login(w http.ResponseWriter, r *http.Request) { if err := a.setup(r.Context()); err != nil { log.Printf("anmeldung: %v", err) http.Redirect(w, r, "/?fehler=anbieter", http.StatusFound) return } state := randomString(16) verifier := oauth2.GenerateVerifier() // State und Prüfwort reisen zusammen im selben Cookie — sie gehören zum // selben Versuch und laufen zusammen ab. setCookie(w, r, StateCookie, state+"|"+verifier, 600, true) http.Redirect(w, r, a.oauth.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier)), http.StatusFound) } func (a *Auth) Callback(w http.ResponseWriter, r *http.Request, st *store.Store) { fail := func(reason string) { setCookie(w, r, StateCookie, "", -1, true) http.Redirect(w, r, "/?fehler="+reason, http.StatusFound) } if err := a.setup(r.Context()); err != nil { log.Printf("rückruf: %v", err) fail("anbieter") return } c, err := r.Cookie(StateCookie) if err != nil { fail("abgelaufen") return } saved := strings.SplitN(c.Value, "|", 2) if len(saved) != 2 || r.URL.Query().Get("state") != saved[0] { fail("state") return } setCookie(w, r, StateCookie, "", -1, true) code := r.URL.Query().Get("code") if code == "" { fail("kein_code") return } token, err := a.oauth.Exchange(r.Context(), code, oauth2.VerifierOption(saved[1])) if err != nil { log.Printf("tausch fehlgeschlagen: %v", err) fail("tausch") return } rawID, ok := token.Extra("id_token").(string) if !ok { fail("kein_id_token") return } idToken, err := a.verifier.Verify(r.Context(), rawID) if err != nil { log.Printf("id-token ungültig: %v", err) fail("token") return } var claims struct { Sub string `json:"sub"` Name string `json:"name"` PreferredUsername string `json:"preferred_username"` Picture string `json:"picture"` } if err := idToken.Claims(&claims); err != nil || claims.Sub == "" { fail("claims") return } name := firstNonEmpty(claims.Name, claims.PreferredUsername, claims.Sub) if !a.allowed(claims.PreferredUsername, claims.Sub) { log.Printf("anmeldung abgelehnt: %q steht nicht in HONEYOS_ALLOWED_USERS", name) fail("nicht_erlaubt") return } if err := st.SeenUser(claims.Sub, name, claims.Picture); err != nil { log.Printf("konnte Nutzer nicht merken: %v", err) } a.issueSession(w, r, claims.Sub) log.Printf("angemeldet: %s", name) http.Redirect(w, r, "/", http.StatusFound) } func (a *Auth) allowed(username, sub string) bool { if len(a.cfg.AllowedUsers) == 0 { return true } for _, u := range a.cfg.AllowedUsers { if strings.EqualFold(u, username) || u == sub { return true } } return false } /* ── Kleinkram ───────────────────────────────────────────────────────────── */ func setCookie(w http.ResponseWriter, r *http.Request, name, value string, maxAge int, httpOnly bool) { http.SetCookie(w, &http.Cookie{ Name: name, Value: value, Path: "/", MaxAge: maxAge, HttpOnly: httpOnly, Secure: isHTTPS(r), SameSite: http.SameSiteLaxMode, }) } // isHTTPS glaubt der Kopfzeile des Reverse Proxy — ohne sie steht hier immer // http, und ein Secure-Cookie käme nie an. func isHTTPS(r *http.Request) bool { if r.TLS != nil { return true } return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") } func randomString(n int) string { b := make([]byte, n) if _, err := rand.Read(b); err != nil { // Ohne Zufall ist jede Sitzung wertlos — dann lieber laut sterben. panic("kein Zufall verfügbar: " + err.Error()) } return base64.RawURLEncoding.EncodeToString(b) } func firstNonEmpty(vals ...string) string { for _, v := range vals { if strings.TrimSpace(v) != "" { return v } } return "" }