nsfwapp/backend/auth_passkey.go
2026-05-12 09:25:27 +02:00

576 lines
14 KiB
Go

// 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 {
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})
}
}