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>
This commit is contained in:
1 parent
d08b0a229c
commit
52deffa30e
20 files changed
+780
-624
No files matched your search
@@ -0,0 +1,315 @@
|
||||
// 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 ""
|
||||
}
|
||||
Reference in new issue
Block a user