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>
316 lines
8.9 KiB
Go
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 ""
|
|
}
|