Files
HoneyOS/internal/auth/auth.go
T
marcandClaude Opus 5 52deffa30e refactor: aufgeraeumt — Pakete hinten, Widgets vorne
Go: store, auth, weather und config sind eigene Pakete unter internal/. Vorher
lagen fuenf Dateien in einem Topf, und jede kannte jede.

Web: os.js hatte 1425 Zeilen und alles darin. Jetzt core.js (Zustand, Server,
Einstellungen), glyphs.js (die Pixelraster), js/widgets/ mit einem Widget je
Datei, os.js nur noch die Ansichten. Stylesheet nach css/.

Die HoneyBar bleibt eine einzelne Datei ohne Importe — sie wird von fremden
Seiten mit einer Zeile eingebunden, da darf nichts nachladen.

Nebenbei: Das gebaute Binary lag mit 9,8 MB im Repo und ist jetzt ignoriert.
Und der Test gegen eine zweite Versionsnummer geht nicht mehr auf os.js, sondern
durch alle .js — sonst haette ihn dieser Umbau still ausgehebelt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:51:21 +02:00

316 lines
8.9 KiB
Go

// 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 ""
}