feat: Backend mit OIDC-Anmeldung, Apps und Einstellungen im Server
Aus der statischen Seite wird ein Go-Binary mit eingebetteter Oberfläche. Grund: Apps über .env zu pflegen wurde unübersichtlich, und kosmetische Einstellungen sollen auf jedem Gerät gleich sein. - Anmeldung über den eigenen OIDC-Anbieter (PocketID), mit PKCE. Ohne Anmeldung gibt es nichts zu sehen – auch keine App-Liste. - Sitzung als signiertes Cookie ohne Gegenstück im Server; Schreibzugriffe zusätzlich über ein lesbares CSRF-Cookie abgesichert - Apps werden in der Oberfläche angelegt und bearbeitet, nicht mehr in der .env. Der Server prüft jeden Eintrag und meldet zurück, was er behält. - Einstellungen liegen je Nutzer im Server; localStorage ist nur noch der Vorschuss, damit die Röhre beim Laden nicht in der falschen Farbe blitzt - die Leiste holt die App-Liste aus /api/apps – freigegeben sind allein die Ursprünge, die selbst als App eingetragen sind - Tests fahren einen echten OIDC-Anbieter hoch und gehen den Anmeldeweg einmal ganz durch Ohne HONEYOS_OIDC_ISSUER läuft alles offen – für die Entwicklung, mit deutlicher Warnung beim Start. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
84cc5d619e
commit
283f9a1850
20 files changed
+1831
-438
No files matched your search
@@ -0,0 +1,296 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"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 (
|
||||
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
|
||||
secret []byte
|
||||
|
||||
once sync.Once
|
||||
initErr error
|
||||
provider *gooidc.Provider
|
||||
oauth *oauth2.Config
|
||||
verifier *gooidc.IDTokenVerifier
|
||||
}
|
||||
|
||||
func NewAuth(cfg 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.
|
||||
func (a *Auth) SubFromRequest(r *http.Request) (string, bool) {
|
||||
if !a.Enabled() {
|
||||
return localSub, true // offener Betrieb, siehe Config.Open
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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, 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 := store.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