// 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") } appLogln("🔐 WebAuthn allowed origins:", strings.Join(origins, ", ")) appLogln("🔐 AUTH_RP_ID:", strings.TrimSpace(os.Getenv("AUTH_RP_ID"))) 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 { 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 == "" { raw = strings.Join([]string{ "https://l14pbbk95100006.tegdssd.de:9999", "https://localhost:9999", "https://127.0.0.1:9999", "https://10.0.1.25:9999", "http://localhost:9999", "http://127.0.0.1:9999", "http://10.0.1.25:9999", "http://l14pbbk95100006.tegdssd.de:5173", "http://localhost:5173", "http://127.0.0.1:5173", "http://10.0.1.25:5173", }, ",") } parts := strings.Split(raw, ",") out := make([]string, 0, len(parts)) seen := map[string]bool{} for _, p := range parts { origin := strings.TrimSpace(p) origin = strings.TrimRight(origin, "/") if origin == "" { continue } if seen[origin] { continue } seen[origin] = true out = append(out, origin) } return out } func originFromRequest(r *http.Request) string { origin := strings.TrimSpace(r.Header.Get("Origin")) if origin != "" { 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 := strings.ToLower(strings.TrimSpace(u.Hostname())) if host == "" { return "", errors.New("origin has no hostname") } if net.ParseIP(host) != nil { return "", errors.New( "Passkeys/WebAuthn funktionieren nicht mit einer IP-Adresse als RP-ID. " + "Öffne die App über einen Hostnamen, z. B. https://recorder.home.arpa:9999", ) } 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 { origin = strings.TrimRight(strings.TrimSpace(origin), "/") for _, a := range allowed { if origin == strings.TrimRight(strings.TrimSpace(a), "/") { return true } } return false } 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) { 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}) } }