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>
297 lines
8.1 KiB
Go
297 lines
8.1 KiB
Go
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 ""
|
|
}
|