// backend\auth_passkey.go package main import ( "crypto/rand" "encoding/base64" "encoding/json" "errors" "net" "net/http" "net/url" "os" "strings" "time" "github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/webauthn" ) const passkeyChallengeCookieName = "pk_challenge" type recorderWebAuthnUser struct { id []byte name string displayName string credentials []webauthn.Credential } func (u recorderWebAuthnUser) WebAuthnID() []byte { return u.id } func (u recorderWebAuthnUser) WebAuthnName() string { return u.name } func (u recorderWebAuthnUser) WebAuthnDisplayName() string { return u.displayName } func (u recorderWebAuthnUser) WebAuthnIcon() string { return "" } func (u recorderWebAuthnUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials } func randomBase64URL(n int) (string, error) { b := make([]byte, n) if _, err := rand.Read(b); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b), nil } func (am *AuthManager) ensurePasskeyUserID() error { am.confMu.Lock() needsID := strings.TrimSpace(am.conf.PasskeyUserID) == "" if needsID { id, err := randomBase64URL(32) if err != nil { am.confMu.Unlock() return err } am.conf.PasskeyUserID = id } am.confMu.Unlock() if needsID { am.persistConfBestEffort() } return nil } func (am *AuthManager) initWebAuthn() error { origins := authAllowedRPOrigins() if len(origins) == 0 { return errors.New("no WebAuthn RP origins configured") } rpID, err := rpIDForOrigin(origins[0]) if err != nil { return err } wa, err := webauthn.New(&webauthn.Config{ RPDisplayName: "Recorder", RPID: rpID, RPOrigins: origins, }) if err != nil { return err } am.webAuthn = wa return nil } func authAllowedRPOrigins() []string { out := make([]string, 0, 16) seen := map[string]bool{} addOrigin := func(origin string) { origin, ok := canonicalOrigin(origin) if !ok || seen[origin] { return } seen[origin] = true out = append(out, origin) } for _, origin := range configuredPublicAppOrigins() { addOrigin(origin) } scheme := "https" if !httpsEnabled() { scheme = "http" } for _, host := range localAppHostCandidates() { addOrigin(originForHost(scheme, host, appPort())) } // Explicit env values remain supported, but they extend the dynamic list // instead of replacing hostname/IP based origins. for _, origin := range configuredAuthOrigins() { addOrigin(origin) } return out } func configuredPublicAppOrigins() []string { keys := []string{ "APP_PUBLIC_URLS", "APP_PUBLIC_URL", "PUBLIC_APP_URLS", "PUBLIC_APP_URL", "NSFWAPP_URL", } out := make([]string, 0, len(keys)) for _, key := range keys { raw := strings.TrimSpace(os.Getenv(key)) if raw == "" { continue } for _, part := range strings.Split(raw, ",") { part = strings.TrimSpace(part) if part != "" { out = append(out, part) } } } return out } func configuredAuthOrigins() []string { raw := strings.TrimSpace(os.Getenv("AUTH_RP_ORIGINS")) // Backward compatible: alter Einzelwert funktioniert weiter. if raw == "" { raw = strings.TrimSpace(os.Getenv("AUTH_RP_ORIGIN")) } if raw == "" { return nil } parts := strings.Split(raw, ",") out := make([]string, 0, len(parts)) for _, p := range parts { p = strings.TrimSpace(p) if p != "" { out = append(out, p) } } return out } func appPort() string { port := strings.TrimSpace(os.Getenv("APP_PORT")) if port == "" { port = "9999" } return port } func originForHost(scheme, host, port string) string { scheme = strings.ToLower(strings.TrimSpace(scheme)) host = normalizeOriginHost(host) port = strings.TrimSpace(port) if scheme == "" || host == "" { return "" } if port == "" { return scheme + "://" + host } return scheme + "://" + net.JoinHostPort(host, port) } func normalizeOriginHost(host string) string { host = strings.TrimSpace(host) if strings.Contains(host, "://") { if u, err := url.Parse(host); err == nil { host = u.Hostname() } } else if h, _, err := net.SplitHostPort(host); err == nil { host = h } host = strings.Trim(host, "[]") host = strings.TrimSuffix(host, ".") return strings.ToLower(host) } func localAppHostCandidates() []string { out := make([]string, 0, 8) seen := map[string]bool{} add := func(host string) { host = normalizeOriginHost(host) if host == "" || seen[host] { return } seen[host] = true out = append(out, host) } add("localhost") add("127.0.0.1") if host := strings.TrimSpace(os.Getenv("APP_HOST")); host != "" { add(host) } if host, err := os.Hostname(); err == nil { add(host) host = normalizeOriginHost(host) if host != "" && !strings.Contains(host, ".") { add(host + ".local") } } addInterfaceIPv4Hosts(add) return out } func addInterfaceIPv4Hosts(add func(string)) { addrs, err := net.InterfaceAddrs() if err != nil { return } for _, addr := range addrs { var ip net.IP switch v := addr.(type) { case *net.IPNet: ip = v.IP case *net.IPAddr: ip = v.IP default: continue } ip4 := ip.To4() if ip4 == nil || ip4.IsLoopback() || ip4.IsUnspecified() || ip4.IsMulticast() { continue } add(ip4.String()) } } func canonicalOrigin(origin string) (string, bool) { origin = strings.TrimRight(strings.TrimSpace(origin), "/") if origin == "" { return "", false } u, err := url.Parse(origin) if err != nil { return "", false } scheme := strings.ToLower(strings.TrimSpace(u.Scheme)) host := normalizeOriginHost(u.Hostname()) if scheme == "" || host == "" { return "", false } if scheme != "http" && scheme != "https" { return "", false } port := strings.TrimSpace(u.Port()) if port == "" { return scheme + "://" + host, true } return scheme + "://" + net.JoinHostPort(host, port), true } func originFromRequest(r *http.Request) string { origin := strings.TrimSpace(r.Header.Get("Origin")) if origin != "" { if normalized, ok := canonicalOrigin(origin); ok { return normalized } return strings.TrimRight(origin, "/") } proto := "http" if isSecureRequest(r) { proto = "https" } host := strings.TrimSpace(r.Host) if host == "" { return "" } return proto + "://" + host } func rpIDFromOrigin(origin string) (string, error) { u, err := url.Parse(strings.TrimSpace(origin)) if err != nil { return "", err } host := normalizeOriginHost(u.Hostname()) if host == "" { return "", errors.New("origin has no hostname") } return host, nil } func rpIDIsValidForOrigin(rpID string, origin string) bool { rpID = strings.ToLower(strings.TrimSpace(rpID)) if rpID == "" { return false } u, err := url.Parse(strings.TrimSpace(origin)) if err != nil { return false } host := strings.ToLower(strings.TrimSpace(u.Hostname())) if host == "" { return false } // IP-Adressen dürfen bei WebAuthn nur exakt als RP-ID verwendet werden. if net.ParseIP(host) != nil { return rpID == host } // localhost darf ebenfalls nur exakt localhost sein. if host == "localhost" { return rpID == "localhost" } // Normale Domains: RP-ID darf Host selbst oder Parent-Domain sein. return host == rpID || strings.HasSuffix(host, "."+rpID) } func rpIDForOrigin(origin string) (string, error) { configured := strings.TrimSpace(os.Getenv("AUTH_RP_ID")) if configured != "" { if rpIDIsValidForOrigin(configured, origin) { return configured, nil } appLogln( "⚠️ AUTH_RP_ID passt nicht zur aktuellen Origin, nutze Origin-Host:", "AUTH_RP_ID=", configured, "origin=", origin, ) } return rpIDFromOrigin(origin) } func originIsAllowed(origin string, allowed []string) bool { normalizedOrigin, ok := canonicalOrigin(origin) if ok { origin = normalizedOrigin } else { origin = strings.TrimRight(strings.TrimSpace(origin), "/") } for _, a := range allowed { normalizedAllowed, ok := canonicalOrigin(a) if ok { a = normalizedAllowed } else { a = strings.TrimRight(strings.TrimSpace(a), "/") } if origin == a { return true } } return false } func requestHostOriginAllowed(origin string, r *http.Request) bool { normalizedOrigin, ok := canonicalOrigin(origin) if !ok { return false } u, err := url.Parse(normalizedOrigin) if err != nil { return false } originHost := normalizeOriginHost(u.Hostname()) requestHost, requestPort := splitRequestHostPort(r.Host) if originHost == "" || requestHost == "" || originHost != requestHost { return false } if strings.TrimSpace(u.Port()) != requestPort { return false } if u.Scheme == "https" { return true } return u.Scheme == "http" && isLocalWebAuthnHost(originHost) } func splitRequestHostPort(host string) (string, string) { host = strings.TrimSpace(host) if host == "" { return "", "" } if h, p, err := net.SplitHostPort(host); err == nil { return normalizeOriginHost(h), strings.TrimSpace(p) } return normalizeOriginHost(host), "" } func isLocalWebAuthnHost(host string) bool { host = normalizeOriginHost(host) if host == "localhost" { return true } ip := net.ParseIP(host) return ip != nil && ip.IsLoopback() } func (am *AuthManager) webAuthnForRequest(r *http.Request) (*webauthn.WebAuthn, error) { origin := originFromRequest(r) if origin == "" { return nil, errors.New("request origin missing") } allowedOrigins := authAllowedRPOrigins() if !originIsAllowed(origin, allowedOrigins) && !requestHostOriginAllowed(origin, r) { return nil, errors.New("origin not allowed for WebAuthn: " + origin) } rpID, err := rpIDForOrigin(origin) if err != nil { return nil, err } appLogln("🔐 WebAuthn Request:", "origin=", origin, "rpID=", rpID) return webauthn.New(&webauthn.Config{ RPDisplayName: "Recorder", RPID: rpID, RPOrigins: []string{origin}, }) } func (am *AuthManager) webAuthnUser() (recorderWebAuthnUser, error) { am.confMu.Lock() defer am.confMu.Unlock() idRaw := strings.TrimSpace(am.conf.PasskeyUserID) id, err := base64.RawURLEncoding.DecodeString(idRaw) if err != nil { return recorderWebAuthnUser{}, err } username := strings.TrimSpace(am.conf.Username) if username == "" { username = "admin" } return recorderWebAuthnUser{ id: id, name: username, displayName: username, credentials: append([]webauthn.Credential(nil), am.conf.Passkeys...), }, nil } func setPasskeyChallengeCookie(w http.ResponseWriter, id string, secure bool) { http.SetCookie(w, &http.Cookie{ Name: passkeyChallengeCookieName, Value: id, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: secure, Expires: time.Now().Add(5 * time.Minute), }) } func clearPasskeyChallengeCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: passkeyChallengeCookieName, Value: "", Path: "/", HttpOnly: true, Expires: time.Unix(0, 0), MaxAge: -1, SameSite: http.SameSiteLaxMode, }) } func (am *AuthManager) savePasskeySession(key string, data *webauthn.SessionData) { am.passkeyMu.Lock() defer am.passkeyMu.Unlock() am.passkeySessions[key] = data } func (am *AuthManager) takePasskeySession(key string) *webauthn.SessionData { am.passkeyMu.Lock() defer am.passkeyMu.Unlock() data := am.passkeySessions[key] delete(am.passkeySessions, key) return data } func authPasskeyRegisterOptionsHandler(am *AuthManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "POST required", http.StatusMethodNotAllowed) return } s, sid := am.getSession(r) if s == nil || !s.Authed || sid == "" { http.Error(w, "unauthorized", http.StatusUnauthorized) return } user, err := am.webAuthnUser() if err != nil { http.Error(w, "passkey user error", http.StatusInternalServerError) return } wa, err := am.webAuthnForRequest(r) if err != nil { appLogln("passkey registration options config failed:", err) http.Error(w, "passkey registration options config failed: "+err.Error(), http.StatusBadRequest) return } options, sessionData, err := wa.BeginRegistration( user, webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred), webauthn.WithExclusions(webauthn.Credentials(user.WebAuthnCredentials()).CredentialDescriptors()), webauthn.WithExtensions(map[string]any{"credProps": true}), ) if err != nil { http.Error(w, "passkey registration options failed", http.StatusInternalServerError) return } am.savePasskeySession("register:"+sid, sessionData) w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(options) } } func authPasskeyRegisterVerifyHandler(am *AuthManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "POST required", http.StatusMethodNotAllowed) return } s, sid := am.getSession(r) if s == nil || !s.Authed || sid == "" { http.Error(w, "unauthorized", http.StatusUnauthorized) return } sessionData := am.takePasskeySession("register:" + sid) if sessionData == nil { http.Error(w, "passkey challenge expired", http.StatusBadRequest) return } user, err := am.webAuthnUser() if err != nil { http.Error(w, "passkey user error", http.StatusInternalServerError) return } wa, err := am.webAuthnForRequest(r) if err != nil { appLogln("passkey registration verify config failed:", err) http.Error(w, "passkey registration verify config failed: "+err.Error(), http.StatusBadRequest) return } credential, err := wa.FinishRegistration(user, *sessionData, r) if err != nil { appLogln("passkey registration verify failed:", err) http.Error(w, "passkey registration verify failed: "+err.Error(), http.StatusUnauthorized) return } now := time.Now().Format(time.RFC3339) credentialID := credentialIDString(credential.ID) am.confMu.Lock() am.conf.Passkeys = append(am.conf.Passkeys, *credential) am.conf.PasskeyInfos = upsertPasskeyInfo(am.conf.PasskeyInfos, passkeyInfo{ CredentialID: credentialID, Label: "Passkey " + shortCredentialID(credentialID), CreatedAt: now, }) am.confMu.Unlock() go am.persistConfBestEffort() w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) } } func authPasskeyLoginOptionsHandler(am *AuthManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "POST required", http.StatusMethodNotAllowed) return } user, err := am.webAuthnUser() if err != nil { http.Error(w, "passkey user error", http.StatusInternalServerError) return } if len(user.credentials) == 0 { http.Error(w, "no passkey configured", http.StatusBadRequest) return } wa, err := am.webAuthnForRequest(r) if err != nil { appLogln("passkey login options config failed:", err) http.Error(w, "passkey login options config failed: "+err.Error(), http.StatusBadRequest) return } options, sessionData, err := wa.BeginLogin( user, webauthn.WithUserVerification(protocol.VerificationPreferred), ) if err != nil { http.Error(w, "passkey login options failed", http.StatusInternalServerError) return } challengeID, err := randomBase64URL(24) if err != nil { http.Error(w, "challenge error", http.StatusInternalServerError) return } am.savePasskeySession("login:"+challengeID, sessionData) setPasskeyChallengeCookie(w, challengeID, isSecureRequest(r)) w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(options) } } func authPasskeyLoginVerifyHandler(am *AuthManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "POST required", http.StatusMethodNotAllowed) return } c, err := r.Cookie(passkeyChallengeCookieName) if err != nil || strings.TrimSpace(c.Value) == "" { http.Error(w, "passkey challenge missing", http.StatusBadRequest) return } challengeID := strings.TrimSpace(c.Value) clearPasskeyChallengeCookie(w) sessionData := am.takePasskeySession("login:" + challengeID) if sessionData == nil { http.Error(w, "passkey challenge expired", http.StatusBadRequest) return } user, err := am.webAuthnUser() if err != nil { http.Error(w, "passkey user error", http.StatusInternalServerError) return } wa, err := am.webAuthnForRequest(r) if err != nil { appLogln("passkey login verify config failed:", err) http.Error(w, "passkey login verify config failed: "+err.Error(), http.StatusBadRequest) return } credential, err := wa.FinishLogin(user, *sessionData, r) if err != nil { appLogln("passkey login verify failed:", err) http.Error(w, "passkey login verify failed: "+err.Error(), http.StatusUnauthorized) return } credentialID := credentialIDString(credential.ID) nowISO := time.Now().Format(time.RFC3339) am.confMu.Lock() for i := range am.conf.Passkeys { if string(am.conf.Passkeys[i].ID) == string(credential.ID) { am.conf.Passkeys[i].Authenticator.SignCount = credential.Authenticator.SignCount break } } am.conf.PasskeyInfos = upsertPasskeyInfo(am.conf.PasskeyInfos, passkeyInfo{ CredentialID: credentialID, LastUsedAt: nowISO, }) username := am.conf.Username am.confMu.Unlock() go am.persistConfBestEffort() sid, err := newSessionID() if err != nil { http.Error(w, "session error", http.StatusInternalServerError) return } now := time.Now() s := &session{ User: username, Authed: true, Pending2FA: false, CreatedAt: now, LastSeenAt: now, ExpiresAt: now.Add(sessionTTL), } am.sessMu.Lock() am.sess[sid] = s am.sessMu.Unlock() setSessionCookie(w, sid, isSecureRequest(r)) w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) } }