From 385d2dfd10d821b25c8a6766bd16eea248cb1652 Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 11 Sep 2026 23:15:29 +0800 Subject: [PATCH] feat(auth): add safe multi-RP ID passkey support Preserve legacy credentials across primary RP ID changes and bind successful assertions to their verified RP ID. Share domain selection across sign-in, login verification and sensitive operations without replaying ceremonies. Save domain settings atomically, preview known and unknown credential impact, and require fresh confirmation before removing domains still in use. Recheck trusted RP IDs when persisting credentials and record safe operation audits. Use read-only configuration snapshots, accept trusted HTTPS intranet hosts, check registration origins early, and preserve historical capitalization. Update settings guidance and all supported translations. Validation: SQLite 3.50.4, MySQL 8.0.46 and PostgreSQL 16.15, including fresh and released-schema upgrade checks; Go regression and race checks; 79 frontend tests; TypeScript, lint, frontend build and root Go build. Security references: OWASP ASVS 5.0.0 V6.3.4 and V6.3.8, Authentication and Session Management Cheat Sheets, and the WebAuthn RP ID/origin rules. --- controller/audit.go | 34 + controller/login_verification.go | 18 +- controller/misc.go | 5 +- controller/option.go | 62 +- controller/passkey.go | 81 +- controller/passkey_test.go | 716 ++++++++++++++++++ controller/secure_verification.go | 19 + controller/security_enrollment_test.go | 2 +- i18n/keys.go | 13 +- i18n/locales/en.yaml | 3 + i18n/locales/zh-CN.yaml | 3 + i18n/locales/zh-TW.yaml | 3 + model/option.go | 18 + model/passkey.go | 72 +- model/passkey_option.go | 270 +++++++ model/user_authentication_test.go | 21 +- router/api-router.go | 1 + service/account_security.go | 2 +- service/passkey/service.go | 96 ++- service/passkey/session.go | 6 + service/security_verification.go | 2 +- setting/system_setting/passkey.go | 145 +++- .../__tests__/domain-verification.test.tsx | 419 ++++++++++ web/src/features/auth/passkey/api.ts | 25 +- web/src/features/auth/passkey/assertion.ts | 120 +++ .../components/passkey-domain-selector.tsx | 172 +++++ web/src/features/auth/passkey/types.ts | 1 + .../__tests__/verification-flow.test.tsx | 22 +- .../features/auth/secure-verification/api.ts | 109 ++- .../components/secure-verification-dialog.tsx | 13 +- .../hooks/use-secure-verification.ts | 20 +- .../auth/secure-verification/types.ts | 2 +- .../sign-in/components/user-auth-form.tsx | 71 +- web/src/features/system-settings/api.ts | 16 + .../auth/__tests__/passkey-settings.test.tsx | 321 ++++++-- .../features/system-settings/auth/index.tsx | 1 + .../system-settings/auth/passkey-section.tsx | 542 ++++++++----- .../hooks/use-update-option.ts | 34 +- web/src/features/system-settings/types.ts | 27 + web/src/features/usage-logs/lib/format.ts | 7 + web/src/i18n/locales/en.json | 29 + web/src/i18n/locales/fr.json | 29 + web/src/i18n/locales/ja.json | 29 + web/src/i18n/locales/ru.json | 29 + web/src/i18n/locales/vi.json | 29 + web/src/i18n/locales/zh-TW.json | 29 + web/src/i18n/locales/zh.json | 29 + web/src/lib/server-error-message.ts | 4 + 48 files changed, 3280 insertions(+), 441 deletions(-) create mode 100644 model/passkey_option.go create mode 100644 web/src/features/auth/passkey/__tests__/domain-verification.test.tsx create mode 100644 web/src/features/auth/passkey/assertion.ts create mode 100644 web/src/features/auth/passkey/components/passkey-domain-selector.tsx diff --git a/controller/audit.go b/controller/audit.go index d9c5bc411..5ec1ec45a 100644 --- a/controller/audit.go +++ b/controller/audit.go @@ -1,8 +1,10 @@ package controller import ( + "errors" "fmt" "os" + "strings" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" @@ -43,6 +45,11 @@ var auditContentTemplates = map[string]string{ "user.reset_passkey": "Reset the user passkey", "option.update": "Updated system setting ${key}", + "option.passkey_domains": "Updated Passkey domains: removed ${domains}; affected ${known}; unknown ${unknown}", + "option.passkey_domains_confirmed": "Confirmed removal of Passkey domains: ${domains}; affected ${known}; unknown ${unknown}", + "option.passkey_domains_blocked": "Passkey domain change blocked: ${domains}; affected ${known}; unknown ${unknown}", + "option.passkey_domains_failed": "Passkey domain update failed", + "channel.create": "Created channel ${name} (type ${type}, count ${count})", "channel.update": "Updated channel ${name} (ID: ${id})", "channel.delete": "Deleted channel ${name} (ID: ${id})", @@ -65,6 +72,33 @@ var auditContentTemplates = map[string]string{ "subscription.user_plan_reset": "Reset active plan ${plan_id} subscriptions for user ${target_user_id}", } +func recordPasskeyDomainAudit(c *gin.Context, change *model.PasskeyDomainChange, confirmed bool, err error) { + confirmed = confirmed && err == nil && change != nil && len(change.RemovedRPIDs) > 0 + params := map[string]any{"success": err == nil, "confirmed": confirmed} + if change != nil { + params["domains"] = strings.Join(change.RemovedRPIDs, ", ") + params["removed_rp_ids"] = change.RemovedRPIDs + params["known"] = change.AffectedCredentials + params["unknown"] = change.UnknownCredentials + params["previous_rp_id"] = change.PreviousRPID + params["effective_rp_id"] = change.EffectiveRPID + } + action := "option.passkey_domains" + if errors.Is(err, model.ErrPasskeyDomainRemovalConfirmation) { + action = "option.passkey_domains_blocked" + } else if err != nil { + action = "option.passkey_domains_failed" + } else if confirmed && change != nil && len(change.RemovedRPIDs) > 0 { + action = "option.passkey_domains_confirmed" + } + auditInfo := &model.AuditRequestInfo{ + Method: c.Request.Method, Route: c.FullPath(), Path: c.FullPath(), + Status: c.Writer.Status(), Success: err == nil, + } + model.RecordOperationAuditLog(c.GetInt("id"), c.GetInt("role"), auditContentEN(action, params), c.ClientIP(), action, params, auditOperatorInfo(c), auditInfo, c) + markAuditLogged(c) +} + // auditContentEN 按 action 模板渲染英文兜底文本;未登记的 action 退回 action 本身。 func auditContentEN(action string, params map[string]any) string { tmpl, ok := auditContentTemplates[action] diff --git a/controller/login_verification.go b/controller/login_verification.go index 6d89b30d2..9f2997680 100644 --- a/controller/login_verification.go +++ b/controller/login_verification.go @@ -41,6 +41,7 @@ func VerifyLogin(c *gin.Context) { func LoginPasskeyBegin(c *gin.Context) { var request struct { FlowToken string `json:"flow_token"` + RPID string `json:"rp_id"` } if common.DecodeJson(c.Request.Body, &request) != nil || request.FlowToken == "" { common.ApiErrorMsg(c, "参数错误") @@ -56,7 +57,11 @@ func LoginPasskeyBegin(c *gin.Context) { writeSecurityOperationError(c, err) return } - wa, err := passkeysvc.BuildWebAuthn(c.Request) + credentialRPID := "" + if credential.RPID != nil { + credentialRPID = *credential.RPID + } + wa, rpIDs, err := passkeysvc.BuildLoginWebAuthn(c.Request, request.RPID, credentialRPID) if err != nil { writeSecurityOperationError(c, err) return @@ -75,7 +80,7 @@ func LoginPasskeyBegin(c *gin.Context) { writeSecurityOperationError(c, err) return } - common.ApiSuccess(c, gin.H{"flow_token": token, "expires_at": expiresAt, "options": options}) + common.ApiSuccess(c, gin.H{"flow_token": token, "expires_at": expiresAt, "options": options, "rp_ids": rpIDs}) } func LoginPasskeyFinish(c *gin.Context) { @@ -104,6 +109,7 @@ func LoginPasskeyFinish(c *gin.Context) { writeSecurityOperationError(c, err) return } + c.Set("passkey_rp_id", sessionData.RelyingPartyID) if security.LoginFlowID != verification.Flow.Id || sessionData.UserVerification != protocol.VerificationRequired { writeSecurityOperationError(c, model.ErrAuthFlowInvalid) return @@ -113,17 +119,21 @@ func LoginPasskeyFinish(c *gin.Context) { writeSecurityOperationError(c, err) return } - wa, err := passkeysvc.BuildWebAuthn(c.Request) + wa, err := passkeysvc.BuildWebAuthnForRPID(c.Request, sessionData.RelyingPartyID) if err != nil { writeSecurityOperationError(c, err) return } + if credential.RPID != nil && *credential.RPID != "" && *credential.RPID != sessionData.RelyingPartyID { + writeSecurityOperationError(c, service.ErrVerificationFailed) + return + } validated, err := wa.ValidateLogin(passkeysvc.NewWebAuthnUser(&model.User{Id: identity.UserID}, credential), *sessionData, parsed) if err != nil { writeSecurityOperationError(c, err) return } - if err := model.UpdatePasskeyAssertionState(identity.UserID, validated, time.Now()); err != nil { + if err := model.UpdatePasskeyAssertionState(identity.UserID, validated, time.Now(), sessionData.RelyingPartyID); err != nil { writeSecurityOperationError(c, err) return } diff --git a/controller/misc.go b/controller/misc.go index 18105a583..1e688fc09 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -44,10 +44,10 @@ func TestStatus(c *gin.Context) { func GetStatus(c *gin.Context) { cs := console_setting.GetConsoleSetting() + passkeySetting := system_setting.PasskeySettingsSnapshot() common.OptionMapRWMutex.RLock() defer common.OptionMapRWMutex.RUnlock() - passkeySetting := system_setting.GetPasskeySettings() legalSetting := system_setting.GetLegalSettings() data := gin.H{ @@ -117,7 +117,8 @@ func GetStatus(c *gin.Context) { "oidc_display_name": system_setting.GetOIDCSettings().GetEffectiveDisplayName(), "passkey_login": passkeySetting.Enabled, "passkey_display_name": passkeySetting.RPDisplayName, - "passkey_rp_id": passkeySetting.RPID, + "passkey_rp_id": passkeySetting.EffectiveRPID(), + "passkey_rp_ids": passkeySetting.RelyingPartyIDs(), "passkey_origins": passkeySetting.Origins, "passkey_allow_insecure": passkeySetting.AllowInsecureOrigin, "passkey_user_verification": passkeySetting.UserVerification, diff --git a/controller/option.go b/controller/option.go index cff783271..474475b3e 100644 --- a/controller/option.go +++ b/controller/option.go @@ -1,6 +1,7 @@ package controller import ( + "errors" "fmt" "net/http" "slices" @@ -135,6 +136,50 @@ type OptionUpdateRequest struct { Value any `json:"value"` } +func UpdatePasskeyDomains(c *gin.Context) { + var request struct { + RPID *string `json:"rp_id"` + LegacyRPIDs *string `json:"legacy_rp_ids"` + Origins *string `json:"origins"` + Preview bool `json:"preview"` + RemovalConfirmation string `json:"removal_confirmation"` + } + if err := common.DecodeJson(c.Request.Body, &request); err != nil || request.RPID == nil || request.LegacyRPIDs == nil || request.Origins == nil { + common.ApiErrorI18n(c, i18n.MsgInvalidParams) + return + } + change, err := model.UpdatePasskeyDomainOptions(map[string]string{ + "passkey.rp_id": *request.RPID, "passkey.legacy_rp_ids": *request.LegacyRPIDs, "passkey.origins": *request.Origins, + }, request.Preview, request.RemovalConfirmation) + if err != nil { + writePasskeyDomainSettingsError(c, err) + if !request.Preview { + recordPasskeyDomainAudit(c, change, request.RemovalConfirmation != "", err) + } + return + } + if !request.Preview { + recordPasskeyDomainAudit(c, change, request.RemovalConfirmation != "", nil) + } + common.ApiSuccess(c, change) +} + +func writePasskeyDomainSettingsError(c *gin.Context, err error) { + var removal *model.PasskeyDomainRemovalError + if errors.As(err, &removal) { + c.JSON(http.StatusConflict, gin.H{ + "success": false, "code": "PASSKEY_RP_ID_REMOVAL_CONFIRMATION_REQUIRED", + "message": i18n.T(c, i18n.MsgPasskeyRPIDRemovalConfirmation), "data": removal.Change, + }) + return + } + if errors.Is(err, system_setting.ErrPasskeyRPIDInvalid) { + writeSecurityOperationError(c, err) + return + } + common.ApiError(c, err) +} + func UpdateOption(c *gin.Context) { var option OptionUpdateRequest err := common.DecodeJson(c.Request.Body, &option) @@ -431,9 +476,24 @@ func UpdateOption(c *gin.Context) { return } } + if model.IsPasskeyDomainOption(option.Key) { + change, updateErr := model.UpdatePasskeyDomainOptions(map[string]string{option.Key: option.Value.(string)}, false, "") + if updateErr != nil { + writePasskeyDomainSettingsError(c, updateErr) + recordPasskeyDomainAudit(c, change, false, updateErr) + return + } + recordPasskeyDomainAudit(c, change, false, nil) + common.ApiSuccess(c, change) + return + } err = model.UpdateOption(option.Key, option.Value.(string)) if err != nil { - common.ApiError(c, err) + if errors.Is(err, system_setting.ErrPasskeyRPIDInvalid) { + writeSecurityOperationError(c, err) + } else { + common.ApiError(c, err) + } return } // 出于安全考虑只记录被修改的配置项名称,不记录配置值(可能含密钥等敏感信息)。 diff --git a/controller/passkey.go b/controller/passkey.go index 94fc10665..ad80f09e7 100644 --- a/controller/passkey.go +++ b/controller/passkey.go @@ -26,6 +26,7 @@ type passkeyFinishRequest struct { } type passkeyVerifyBeginRequest struct { + RPID string `json:"rp_id"` Scope string `json:"scope"` Context json.RawMessage `json:"context,omitempty"` } @@ -42,7 +43,7 @@ func parsePasskeyFinishRequest(c *gin.Context) (*passkeyFinishRequest, error) { } func PasskeyRegisterBegin(c *gin.Context) { - if !system_setting.GetPasskeySettings().Enabled { + if !system_setting.PasskeySettingsSnapshot().Enabled { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "管理员未启用 Passkey 登录", @@ -118,7 +119,7 @@ func PasskeyRegisterBegin(c *gin.Context) { } func PasskeyRegisterFinish(c *gin.Context) { - if !system_setting.GetPasskeySettings().Enabled { + if !system_setting.PasskeySettingsSnapshot().Enabled { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "管理员未启用 Passkey 登录", @@ -142,12 +143,6 @@ func PasskeyRegisterFinish(c *gin.Context) { return } - wa, err := passkeysvc.BuildWebAuthn(c.Request) - if err != nil { - writeSecurityOperationError(c, err) - return - } - credentialRecord, err := model.GetPasskeyByUserID(user.Id) if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) { writeSecurityOperationError(c, err) @@ -171,6 +166,12 @@ func PasskeyRegisterFinish(c *gin.Context) { writeSecurityOperationError(c, err) return } + wa, err := passkeysvc.BuildWebAuthnForRPID(c.Request, sessionData.RelyingPartyID) + if err != nil { + writeSecurityOperationError(c, err) + return + } + c.Set("passkey_rp_id", sessionData.RelyingPartyID) if sessionData.UserVerification != protocol.VerificationRequired { writeSecurityOperationError(c, model.ErrAuthFlowInvalid) return @@ -193,6 +194,7 @@ func PasskeyRegisterFinish(c *gin.Context) { return } + passkeyCredential.RPID = &sessionData.RelyingPartyID if err := model.RegisterPasskeyForSession(identity, passkeyCredential); err != nil { writeSecurityOperationError(c, err) return @@ -281,7 +283,7 @@ func PasskeyStatus(c *gin.Context) { } func PasskeyLoginBegin(c *gin.Context) { - if !system_setting.GetPasskeySettings().Enabled { + if !system_setting.PasskeySettingsSnapshot().Enabled { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "管理员未启用 Passkey 登录", @@ -289,7 +291,16 @@ func PasskeyLoginBegin(c *gin.Context) { return } - wa, err := passkeysvc.BuildWebAuthn(c.Request) + var request struct { + RPID string `json:"rp_id"` + } + if c.Request.Body != nil && c.Request.ContentLength != 0 { + if common.DecodeJson(c.Request.Body, &request) != nil { + common.ApiErrorMsg(c, "无效的 Passkey 验证请求") + return + } + } + wa, rpIDs, err := passkeysvc.BuildLoginWebAuthn(c.Request, request.RPID, "") if err != nil { writeSecurityOperationError(c, err) return @@ -316,6 +327,7 @@ func PasskeyLoginBegin(c *gin.Context) { "message": "", "data": gin.H{ "options": assertion, + "rp_ids": rpIDs, "flow_token": flowToken, "expires_at": expiresAt, }, @@ -323,7 +335,7 @@ func PasskeyLoginBegin(c *gin.Context) { } func PasskeyLoginFinish(c *gin.Context) { - if !system_setting.GetPasskeySettings().Enabled { + if !system_setting.PasskeySettingsSnapshot().Enabled { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "管理员未启用 Passkey 登录", @@ -342,12 +354,6 @@ func PasskeyLoginFinish(c *gin.Context) { return } - wa, err := passkeysvc.BuildWebAuthn(c.Request) - if err != nil { - writeSecurityOperationError(c, err) - return - } - sessionData, _, err := passkeysvc.PopSessionDataFlow( request.FlowToken, model.AuthFlowPurposePasskeyLogin, @@ -357,6 +363,12 @@ func PasskeyLoginFinish(c *gin.Context) { writeSecurityOperationError(c, err) return } + wa, err := passkeysvc.BuildWebAuthnForRPID(c.Request, sessionData.RelyingPartyID) + if err != nil { + writeSecurityOperationError(c, err) + return + } + c.Set("passkey_rp_id", sessionData.RelyingPartyID) if sessionData.UserVerification != protocol.VerificationRequired { writeSecurityOperationError(c, model.ErrAuthFlowInvalid) return @@ -369,6 +381,10 @@ func PasskeyLoginFinish(c *gin.Context) { return nil, fmt.Errorf("未找到 Passkey 凭证: %w", err) } + if credential.RPID != nil && *credential.RPID != "" && *credential.RPID != sessionData.RelyingPartyID { + return nil, service.ErrVerificationFailed + } + // 通过凭证获取用户 user := &model.User{Id: credential.UserID} if err := user.FillUserById(); err != nil { @@ -415,7 +431,7 @@ func PasskeyLoginFinish(c *gin.Context) { return } - if err := model.UpdatePasskeyAssertionState(modelUser.Id, credential, time.Now()); err != nil { + if err := model.UpdatePasskeyAssertionState(modelUser.Id, credential, time.Now(), sessionData.RelyingPartyID); err != nil { writeSecurityOperationError(c, err) return } @@ -474,7 +490,7 @@ func AdminResetPasskey(c *gin.Context) { } func PasskeyVerifyBegin(c *gin.Context) { - if !system_setting.GetPasskeySettings().Enabled { + if !system_setting.PasskeySettingsSnapshot().Enabled { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "管理员未启用 Passkey 登录", @@ -516,7 +532,11 @@ func PasskeyVerifyBegin(c *gin.Context) { return } - wa, err := passkeysvc.BuildWebAuthn(c.Request) + credentialRPID := "" + if credential.RPID != nil { + credentialRPID = *credential.RPID + } + wa, rpIDs, err := passkeysvc.BuildLoginWebAuthn(c.Request, request.RPID, credentialRPID) if err != nil { writeSecurityOperationError(c, err) return @@ -544,6 +564,7 @@ func PasskeyVerifyBegin(c *gin.Context) { "message": "", "data": gin.H{ "options": assertion, + "rp_ids": rpIDs, "flow_token": flowToken, "expires_at": expiresAt, }, @@ -551,7 +572,7 @@ func PasskeyVerifyBegin(c *gin.Context) { } func PasskeyVerifyFinish(c *gin.Context) { - if !system_setting.GetPasskeySettings().Enabled { + if !system_setting.PasskeySettingsSnapshot().Enabled { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "管理员未启用 Passkey 登录", @@ -576,12 +597,6 @@ func PasskeyVerifyFinish(c *gin.Context) { return } - wa, err := passkeysvc.BuildWebAuthn(c.Request) - if err != nil { - writeSecurityOperationError(c, err) - return - } - credential, err := model.GetPasskeyByUserID(user.Id) if err != nil { c.JSON(http.StatusOK, gin.H{ @@ -605,11 +620,21 @@ func PasskeyVerifyFinish(c *gin.Context) { writeSecurityOperationError(c, err) return } + wa, err := passkeysvc.BuildWebAuthnForRPID(c.Request, sessionData.RelyingPartyID) + if err != nil { + writeSecurityOperationError(c, err) + return + } + c.Set("passkey_rp_id", sessionData.RelyingPartyID) if sessionData.UserVerification != protocol.VerificationRequired { writeSecurityOperationError(c, model.ErrAuthFlowInvalid) return } + if credential.RPID != nil && *credential.RPID != "" && *credential.RPID != sessionData.RelyingPartyID { + writeSecurityOperationError(c, service.ErrVerificationFailed) + return + } waUser := passkeysvc.NewWebAuthnUser(user, credential) validatedCredential, err := wa.ValidateLogin(waUser, *sessionData, parsedCredential) if err != nil { @@ -617,7 +642,7 @@ func PasskeyVerifyFinish(c *gin.Context) { return } - if err := model.UpdatePasskeyAssertionState(user.Id, validatedCredential, time.Now()); err != nil { + if err := model.UpdatePasskeyAssertionState(user.Id, validatedCredential, time.Now(), sessionData.RelyingPartyID); err != nil { writeSecurityOperationError(c, err) return } diff --git a/controller/passkey_test.go b/controller/passkey_test.go index f1150f7ec..8702a5e9a 100644 --- a/controller/passkey_test.go +++ b/controller/passkey_test.go @@ -1,9 +1,15 @@ package controller import ( + "bytes" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "maps" "net/http" "net/http/httptest" "strings" @@ -11,18 +17,705 @@ import ( "time" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/service" + passkeysvc "github.com/QuantumNous/new-api/service/passkey" "github.com/QuantumNous/new-api/setting/system_setting" "github.com/gin-gonic/gin" + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gorm.io/gorm" ) type passkeyTestBody struct { *strings.Reader } +type passkeyDomainBegin struct { + FlowToken string `json:"flow_token"` + RPIDs []string `json:"rp_ids"` + Options struct { + PublicKey struct { + Challenge string `json:"challenge"` + RPID string `json:"rpId"` + RP struct { + ID string `json:"id"` + } `json:"rp"` + } `json:"publicKey"` + } `json:"options"` +} + +func passkeyDomainRequest(t *testing.T, path string, payload any, identity service.AuthIdentity, origin string, handler gin.HandlerFunc) *httptest.ResponseRecorder { + t.Helper() + body, err := common.Marshal(payload) + require.NoError(t, err) + return securityEnrollmentRequest(http.MethodPost, path, string(body), "", identity, func(c *gin.Context) { + c.Request.Header.Set("Origin", origin) + handler(c) + }) +} + +func decodePasskeyDomainBegin(t *testing.T, response *httptest.ResponseRecorder) passkeyDomainBegin { + t.Helper() + var result securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + require.True(t, result.Success, response.Body.String()) + var begin passkeyDomainBegin + require.NoError(t, common.Unmarshal(result.Data, &begin)) + return begin +} + +// Reuse the existing software authenticator, replacing the domain-bound bytes +// and recomputing the signature exactly as a browser/authenticator would. +func passkeyDomainAssertion(t *testing.T, key *ecdsa.PrivateKey, challenge, rpID, origin string, userID int, verified bool) map[string]any { + t.Helper() + var assertion map[string]any + require.NoError(t, common.Unmarshal(securityPasskeyResponse(t, key, challenge, false, 1, verified), &assertion)) + response := assertion["response"].(map[string]any) + clientData, err := common.Marshal(map[string]string{"type": "webauthn.get", "challenge": challenge, "origin": origin}) + require.NoError(t, err) + authData, err := base64.RawURLEncoding.DecodeString(response["authenticatorData"].(string)) + require.NoError(t, err) + rpHash := sha256.Sum256([]byte(rpID)) + copy(authData, rpHash[:]) + clientHash := sha256.Sum256(clientData) + signedHash := sha256.Sum256(append(append([]byte{}, authData...), clientHash[:]...)) + signature, err := ecdsa.SignASN1(rand.Reader, key, signedHash[:]) + require.NoError(t, err) + response["clientDataJSON"] = base64.RawURLEncoding.EncodeToString(clientData) + response["authenticatorData"] = base64.RawURLEncoding.EncodeToString(authData) + response["signature"] = base64.RawURLEncoding.EncodeToString(signature) + response["userHandle"] = base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprint(userID))) + return assertion +} + +func TestPasskeyDomainsPreserveCredentialsAcrossVerificationFlows(t *testing.T) { + for _, kind := range []string{"direct", "login factor", "sensitive action", "historical mixed case"} { + t.Run(kind, func(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + key := newSecurityLoginPasskey(t, user.Id) + settings := system_setting.GetPasskeySettings() + legacyRPID := "www.example.com" + if kind == "historical mixed case" { + legacyRPID = "WWW.example.com" + } + settings.LegacyRPIDs = legacyRPID + settings.Origins = "https://example.com,https://www.example.com" + beginPath, finishPath := "/api/user/passkey/login/begin", "/api/user/passkey/login/finish" + beginHandler, finishHandler := PasskeyLoginBegin, PasskeyLoginFinish + request := map[string]any{"rp_id": legacyRPID} + if kind == "login factor" { + pending, err := service.StartLoginVerification(user, "password") + require.NoError(t, err) + request["flow_token"] = pending.FlowToken + beginPath, finishPath = "/api/user/login/passkey/begin", "/api/user/login/passkey/finish" + beginHandler, finishHandler = LoginPasskeyBegin, LoginPasskeyFinish + } else if kind == "sensitive action" { + request["scope"] = service.VerificationScopeAccessTokenGenerate + beginPath, finishPath = "/api/user/passkey/verify/begin", "/api/user/passkey/verify/finish" + beginHandler, finishHandler = PasskeyVerifyBegin, PasskeyVerifyFinish + } + begin := decodePasskeyDomainBegin(t, passkeyDomainRequest(t, beginPath, request, identity, "https://www.example.com", beginHandler)) + assert.Equal(t, legacyRPID, begin.Options.PublicKey.RPID) + assert.Equal(t, []string{"example.com", legacyRPID}, begin.RPIDs) + before, err := model.GetPasskeyByUserID(user.Id) + require.NoError(t, err) + require.Nil(t, before.RPID) + finish := map[string]any{"flow_token": begin.FlowToken, "credential": passkeyDomainAssertion(t, key, begin.Options.PublicKey.Challenge, legacyRPID, "https://www.example.com", user.Id, true)} + if kind == "login factor" { + finish["flow_token"], finish["passkey_flow_token"] = request["flow_token"], begin.FlowToken + } + response := passkeyDomainRequest(t, finishPath, finish, identity, "https://www.example.com", finishHandler) + var result securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + require.True(t, result.Success, response.Body.String()) + after, err := model.GetPasskeyByUserID(user.Id) + require.NoError(t, err) + require.NotNil(t, after.RPID) + assert.Equal(t, legacyRPID, *after.RPID) + assert.Equal(t, before.CredentialID, after.CredentialID) + assert.Equal(t, before.PublicKey, after.PublicKey) + if kind == "sensitive action" { + var proof service.SecurityProof + require.NoError(t, common.Unmarshal(result.Data, &proof)) + assert.Equal(t, service.VerificationScopeAccessTokenGenerate, proof.Scope) + } + count, err := model.CountActiveUserSessions(user.Id, time.Now().Unix()) + require.NoError(t, err) + replay := passkeyDomainRequest(t, finishPath, finish, identity, "https://www.example.com", finishHandler) + require.NoError(t, common.Unmarshal(replay.Body.Bytes(), &result)) + assert.False(t, result.Success) + afterCount, err := model.CountActiveUserSessions(user.Id, time.Now().Unix()) + require.NoError(t, err) + assert.Equal(t, count, afterCount) + // Once identified, a browser hint cannot override the credential's binding. + wa, ids, err := passkeysvc.BuildLoginWebAuthn(httptest.NewRequest(http.MethodPost, beginPath, nil), "example.com", *after.RPID) + require.NoError(t, err) + assert.Equal(t, legacyRPID, wa.Config.RPID) + assert.Equal(t, []string{legacyRPID}, ids) + }) + } +} + +func TestPasskeyDomainsRejectInvalidAssertions(t *testing.T) { + for _, failure := range []string{"wrong RP ID", "unlisted origin", "origin outside RP scope", "wrong user", "bad signature", "missing user verification", "expired challenge", "removed domain", "known different domain"} { + t.Run(failure, func(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + key := newSecurityLoginPasskey(t, user.Id) + settings := system_setting.GetPasskeySettings() + settings.LegacyRPIDs = "www.example.com" + settings.Origins = "https://example.com,https://www.example.com" + begin := decodePasskeyDomainBegin(t, passkeyDomainRequest(t, "/api/user/passkey/login/begin", map[string]string{"rp_id": "www.example.com"}, identity, "https://www.example.com", PasskeyLoginBegin)) + rpID, origin, handle := "www.example.com", "https://www.example.com", user.Id + switch failure { + case "wrong RP ID": + rpID = "example.com" + case "unlisted origin": + origin = "https://evil.example.com" + case "origin outside RP scope": + origin = "https://example.com" + case "wrong user": + handle++ + case "expired challenge": + flow, err := model.GetAuthFlow(begin.FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposePasskeyLogin}) + require.NoError(t, err) + var payload struct { + SessionData webauthn.SessionData `json:"session_data"` + Security passkeysvc.FlowSecurity `json:"security"` + } + require.NoError(t, common.UnmarshalJsonStr(flow.Payload, &payload)) + payload.SessionData.Expires = time.Now().Add(-time.Second) + encoded, err := common.Marshal(payload) + require.NoError(t, err) + require.NoError(t, model.DB.Model(flow).Update("payload", string(encoded)).Error) + case "removed domain": + settings.LegacyRPIDs = "" + case "known different domain": + require.NoError(t, model.DB.Model(&model.PasskeyCredential{}).Where("user_id = ?", user.Id).Update("rp_id", "example.com").Error) + } + assertion := passkeyDomainAssertion(t, key, begin.Options.PublicKey.Challenge, rpID, origin, handle, failure != "missing user verification") + if failure == "bad signature" { + assertion["response"].(map[string]any)["signature"] = "AA" + } + response := passkeyDomainRequest(t, "/api/user/passkey/login/finish", map[string]any{"flow_token": begin.FlowToken, "credential": assertion}, identity, "https://www.example.com", PasskeyLoginFinish) + var result securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + assert.False(t, result.Success, response.Body.String()) + assert.Empty(t, response.Header().Values("Set-Cookie")) + count, err := model.CountActiveUserSessions(user.Id, time.Now().Unix()) + require.NoError(t, err) + assert.EqualValues(t, 1, count) + stored, err := model.GetPasskeyByUserID(user.Id) + require.NoError(t, err) + if failure == "known different domain" { + assert.Equal(t, "example.com", *stored.RPID) + } else { + assert.Nil(t, stored.RPID) + } + }) + } +} + +func TestPasskeyDomainChoicesRespectOriginAndConfiguration(t *testing.T) { + _, identity := setupSecurityEnrollmentTest(t) + settings := system_setting.GetPasskeySettings() + settings.LegacyRPIDs = "www.example.com" + settings.Origins = "https://example.com,https://www.example.com" + for _, test := range []struct { + origin, hint string + success bool + ids []string + }{ + {"https://www.example.com", "", true, []string{"example.com", "www.example.com"}}, + {"https://example.com", "", true, []string{"example.com"}}, + {"https://example.com", "www.example.com", false, nil}, + {"https://www.example.com", "unconfigured.example.com", false, nil}, + {"https://attacker.test", "", false, nil}, + } { + response := passkeyDomainRequest(t, "/api/user/passkey/login/begin", map[string]string{"rp_id": test.hint}, identity, test.origin, PasskeyLoginBegin) + var result securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + assert.Equal(t, test.success, result.Success, "%+v", test) + if test.success { + assert.Equal(t, test.ids, decodePasskeyDomainBegin(t, response).RPIDs) + } else { + assert.Equal(t, "PASSKEY_RP_ID_UNAVAILABLE", result.Code) + } + } + settings.RPID, settings.LegacyRPIDs, settings.Origins, settings.AllowInsecureOrigin = "localhost", "", "http://localhost:3000,http://localhost:3001", true + for _, origin := range []string{"http://localhost:3000", "http://localhost:3001"} { + begin := decodePasskeyDomainBegin(t, passkeyDomainRequest(t, "/api/user/passkey/login/begin", nil, identity, origin, PasskeyLoginBegin)) + assert.Equal(t, "localhost", begin.Options.PublicKey.RPID) + } +} + +func setupPasskeyDomainOptions(t *testing.T) { + t.Helper() + require.NoError(t, model.DB.AutoMigrate(&model.Option{})) + common.OptionMapRWMutex.RLock() + previousOptions := maps.Clone(common.OptionMap) + previousAddress := system_setting.ServerAddress + common.OptionMapRWMutex.RUnlock() + t.Cleanup(func() { + common.OptionMapRWMutex.Lock() + common.OptionMap = previousOptions + system_setting.ServerAddress = previousAddress + common.OptionMapRWMutex.Unlock() + }) +} + +func TestPasskeyDomainRemovalProtectsExistingAndUnknownCredentials(t *testing.T) { + for _, binding := range []string{"www.example.com", ""} { + t.Run("binding="+binding, func(t *testing.T) { + user, _ := setupSecurityEnrollmentTest(t) + setupPasskeyDomainOptions(t) + settings := system_setting.GetPasskeySettings() + settings.LegacyRPIDs = "www.example.com" + settings.Origins = "https://example.com,https://www.example.com" + credential := &model.PasskeyCredential{UserID: user.Id, CredentialID: "existing", PublicKey: "key"} + if binding != "" { + credential.RPID = &binding + } + require.NoError(t, model.DB.Create(credential).Error) + assert.Error(t, model.UpdateOption("passkey.legacy_rp_ids", "")) + assert.Equal(t, "www.example.com", system_setting.PasskeySettingsSnapshot().LegacyRPIDs) + }) + } +} + +func TestPasskeyDomainRemovalWithoutAffectedCredentialsNeedsNoOverride(t *testing.T) { + user, _ := setupSecurityEnrollmentTest(t) + setupPasskeyDomainOptions(t) + settings := system_setting.GetPasskeySettings() + settings.LegacyRPIDs = "www.example.com,WWW.example.com" + upper := "WWW.example.com" + require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, RPID: &upper, CredentialID: "uppercase", PublicKey: "key"}).Error) + change, err := model.UpdatePasskeyDomainOptions(map[string]string{"passkey.legacy_rp_ids": upper}, false, "") + require.NoError(t, err) + assert.Equal(t, []string{"www.example.com"}, change.RemovedRPIDs) + assert.Zero(t, change.AffectedCredentials) + assert.Zero(t, change.UnknownCredentials) + assert.False(t, change.ConfirmationRequired) + assert.Equal(t, upper, settings.LegacyRPIDs) +} + +func TestPasskeySnapshotDoesNotMaterializeDefaults(t *testing.T) { + setupSecurityEnrollmentTest(t) + setupPasskeyDomainOptions(t) + settings := system_setting.GetPasskeySettings() + settings.RPID, settings.Origins = "", "" + system_setting.ServerAddress = "https://www.example.com" + assert.Equal(t, "www.example.com", system_setting.PasskeySettingsSnapshot().EffectiveRPID()) + assert.Empty(t, settings.RPID) + assert.Empty(t, settings.Origins) +} + +func TestPasskeyDomainPreviewConfirmationAndAudit(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + setupPasskeyDomainOptions(t) + settings := system_setting.GetPasskeySettings() + settings.LegacyRPIDs = "www.example.com,WWW.example.com" + settings.Origins = "https://example.com,https://www.example.com" + lower, upper, empty := "www.example.com", "WWW.example.com", "" + for i, binding := range []*string{&lower, &upper, nil, &empty, &lower} { + credential := &model.PasskeyCredential{UserID: user.Id + i, RPID: binding, CredentialID: fmt.Sprint(i), PublicKey: "key"} + require.NoError(t, model.DB.Create(credential).Error) + if i == 4 { + require.NoError(t, model.DB.Delete(credential).Error) + } + } + request := map[string]any{"rp_id": "example.com", "legacy_rp_ids": upper, "origins": settings.Origins, "preview": true} + response := passkeyDomainRequest(t, "/api/option/passkey/domains", request, identity, "https://example.com", UpdatePasskeyDomains) + var result securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + require.True(t, result.Success, response.Body.String()) + var preview model.PasskeyDomainChange + require.NoError(t, common.Unmarshal(result.Data, &preview)) + assert.Equal(t, []string{lower}, preview.RemovedRPIDs) + assert.EqualValues(t, 1, preview.AffectedCredentials) + assert.EqualValues(t, 2, preview.UnknownCredentials) + assert.True(t, preview.ConfirmationRequired) + require.NotEmpty(t, preview.RemovalConfirmation) + options, err := model.AllOption() + require.NoError(t, err) + assert.Empty(t, options, "preview must not materialize default rows") + assert.Equal(t, "www.example.com,WWW.example.com", settings.LegacyRPIDs) + assert.ErrorIs(t, model.UpdateOptionsBulk(map[string]string{"passkey.legacy_rp_ids": upper, "Notice": "must roll back"}), model.ErrPasskeyDomainRemovalConfirmation) + blocked := passkeyDomainRequest(t, "/api/option/", map[string]string{"key": "passkey.legacy_rp_ids", "value": upper}, identity, "https://example.com", UpdateOption) + assert.Equal(t, http.StatusConflict, blocked.Code) + assert.Contains(t, blocked.Body.String(), "PASSKEY_RP_ID_REMOVAL_CONFIRMATION_REQUIRED") + request["preview"] = false + request["removal_confirmation"] = preview.RemovalConfirmation + // A new unknown credential makes the reviewed impact stale. + require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id + 10, CredentialID: "new", PublicKey: "key"}).Error) + response = passkeyDomainRequest(t, "/api/option/passkey/domains", request, identity, "https://example.com", UpdatePasskeyDomains) + assert.Equal(t, http.StatusConflict, response.Code) + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + require.NoError(t, common.Unmarshal(result.Data, &preview)) + assert.EqualValues(t, 3, preview.UnknownCredentials) + options, err = model.AllOption() + require.NoError(t, err) + assert.Empty(t, options) + request["removal_confirmation"] = preview.RemovalConfirmation + response = passkeyDomainRequest(t, "/api/option/passkey/domains", request, identity, "https://example.com", UpdatePasskeyDomains) + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + require.True(t, result.Success, response.Body.String()) + assert.Equal(t, upper, settings.LegacyRPIDs) + var audits []model.AuditLog + require.NoError(t, model.LOG_DB.Find(&audits).Error) + for _, audit := range audits { + if audit.Action == "option.passkey_domains_blocked" { + assert.False(t, audit.Success) + assert.Equal(t, http.StatusConflict, audit.Status) + } + if audit.Action == "option.passkey_domains_confirmed" { + assert.True(t, audit.Success) + require.NotNil(t, audit.Other.Op) + params, err := common.Marshal(audit.Other.Op.Params) + require.NoError(t, err) + var impact struct { + Known, Unknown int64 + Confirmed bool + RemovedRPIDs []string `json:"removed_rp_ids"` + } + require.NoError(t, common.Unmarshal(params, &impact)) + assert.EqualValues(t, 1, impact.Known) + assert.EqualValues(t, 3, impact.Unknown) + assert.True(t, impact.Confirmed) + assert.Equal(t, []string{lower}, impact.RemovedRPIDs) + } + } + encoded, err := common.Marshal(audits) + require.NoError(t, err) + assert.Contains(t, string(encoded), "option.passkey_domains_confirmed") + assert.Contains(t, string(encoded), "option.passkey_domains_blocked") + assert.NotContains(t, string(encoded), preview.RemovalConfirmation) + // Simulate an old node's cache after removal: persistence must use DB trust. + settings.LegacyRPIDs = lower + "," + upper + assert.ErrorIs(t, model.UpdatePasskeyAssertionState(user.Id, &webauthn.Credential{ID: []byte("0")}, time.Now(), lower), system_setting.ErrPasskeyRPIDUnavailable) + assert.ErrorIs(t, model.RegisterPasskeyForSession(identity, &model.PasskeyCredential{UserID: user.Id, RPID: &lower, CredentialID: "replacement", PublicKey: "key"}), system_setting.ErrPasskeyRPIDUnavailable) +} + +func TestPasskeyDomainSettingsAcceptExactHTTPSInternalHosts(t *testing.T) { + for _, test := range []struct { + rpID, origins string + valid bool + }{ + {"intranet", "https://intranet:8443", true}, + {"intranet", "https://child.intranet", false}, + {"intranet", "http://intranet", false}, + {"com", "https://com", false}, + {"127.0.0.1", "https://127.0.0.1", false}, + {"intranet:8443", "https://intranet:8443", false}, + {"localhost", "http://localhost:3000", true}, + } { + t.Run(test.rpID+"/"+test.origins, func(t *testing.T) { + setupSecurityEnrollmentTest(t) + setupPasskeyDomainOptions(t) + change, err := model.UpdatePasskeyDomainOptions(map[string]string{"passkey.rp_id": test.rpID, "passkey.legacy_rp_ids": "", "passkey.origins": test.origins}, false, "") + if !test.valid { + assert.ErrorIs(t, err, system_setting.ErrPasskeyRPIDInvalid) + return + } + require.NoError(t, err) + assert.Equal(t, test.rpID, change.RPID) + assert.Equal(t, "example.com", change.LegacyRPIDs) + }) + } +} + +func TestPasskeyImplicitDomainChangesRetainPreviousTrust(t *testing.T) { + setupSecurityEnrollmentTest(t) + setupPasskeyDomainOptions(t) + settings := system_setting.GetPasskeySettings() + settings.RPID, settings.Origins = "", "" + system_setting.ServerAddress = "https://www.example.com" + require.NoError(t, model.UpdateOption("ServerAddress", "https://example.com")) + assert.Equal(t, []string{"example.com", "www.example.com"}, system_setting.PasskeySettingsSnapshot().RelyingPartyIDs()) + assert.Equal(t, "https://www.example.com,https://example.com", settings.Origins) + // Moving the retained RP into the primary is not a removal. + change, err := model.UpdatePasskeyDomainOptions(map[string]string{"passkey.rp_id": "www.example.com", "passkey.legacy_rp_ids": ""}, false, "") + require.NoError(t, err) + assert.Empty(t, change.RemovedRPIDs) + assert.Equal(t, "example.com", change.LegacyRPIDs) +} + +func TestPasskeyRegistrationAndDomainRemovalSerialize(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + setupPasskeyDomainOptions(t) + system_setting.GetPasskeySettings().Origins = "https://example.com,https://www.example.com" + require.NoError(t, model.UpdateOption("passkey.legacy_rp_ids", "www.example.com")) + registering, release := make(chan struct{}), make(chan struct{}) + require.NoError(t, model.DB.Callback().Create().Before("gorm:create").Register("passkey_registration_barrier", func(tx *gorm.DB) { + if _, ok := tx.Statement.Dest.(*model.PasskeyCredential); ok { + close(registering) + <-release + } + })) + t.Cleanup(func() { _ = model.DB.Callback().Create().Remove("passkey_registration_barrier") }) + rpID := "www.example.com" + registered, removed := make(chan error, 1), make(chan error, 1) + go func() { + registered <- model.RegisterPasskeyForSession(identity, &model.PasskeyCredential{UserID: user.Id, RPID: &rpID, CredentialID: "concurrent", PublicKey: "key"}) + }() + <-registering + removing := make(chan struct{}) + go func() { + close(removing) + removed <- model.UpdateOption("passkey.legacy_rp_ids", "") + }() + <-removing + // A concurrent snapshot must remain read-only while the write is pending. + assert.Equal(t, []string{"example.com", "www.example.com"}, system_setting.PasskeySettingsSnapshot().RelyingPartyIDs()) + close(release) + require.NoError(t, <-registered) + assert.ErrorIs(t, <-removed, model.ErrPasskeyDomainRemovalConfirmation) + stored, err := model.GetPasskeyByUserID(user.Id) + require.NoError(t, err) + require.NotNil(t, stored.RPID) + assert.Equal(t, rpID, *stored.RPID) +} + +func TestPasskeyDomainEndpointRequiresRoot(t *testing.T) { + user, _ := setupSecurityEnrollmentTest(t) + setupPasskeyDomainOptions(t) + bundle, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "domain-settings-test") + require.NoError(t, err) + router := gin.New() + router.PUT("/api/option/passkey/domains", middleware.RootAuth(), UpdatePasskeyDomains) + for _, bearer := range []string{"", bundle.AccessToken} { + request := httptest.NewRequest(http.MethodPut, "/api/option/passkey/domains", strings.NewReader(`{"rp_id":"example.com","legacy_rp_ids":"","origins":"https://example.com","preview":true}`)) + if bearer != "" { + request.Header.Set("Authorization", "Bearer "+bearer) + } + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + assert.Contains(t, []int{http.StatusUnauthorized, http.StatusForbidden}, response.Code) + } + options, err := model.AllOption() + require.NoError(t, err) + assert.Empty(t, options) +} + +func TestPasskeyDefaultsDoNotRestrictUnrelatedServerAddressSetup(t *testing.T) { + setupSecurityEnrollmentTest(t) + setupPasskeyDomainOptions(t) + settings := system_setting.GetPasskeySettings() + settings.Enabled, settings.RPID, settings.LegacyRPIDs, settings.Origins = false, "", "", "" + system_setting.ServerAddress = "" + require.NoError(t, model.UpdateOption("ServerAddress", "http://127.0.0.1:3000")) + assert.Equal(t, "http://127.0.0.1:3000", system_setting.ServerAddress) + assert.Empty(t, settings.RPID) + assert.Empty(t, settings.LegacyRPIDs) +} + +func TestPasskeyRegistrationRejectsUnavailableRequestOrigin(t *testing.T) { + _, identity := setupSecurityEnrollmentTest(t) + settings := system_setting.GetPasskeySettings() + settings.RPID = "www.example.com" + settings.LegacyRPIDs = "example.com" + settings.Origins = "https://example.com,https://www.example.com" + proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: "passkey.register"}, "password") + response := securityEnrollmentRequest(http.MethodPost, "/api/user/passkey/register/begin", "", proof, identity, func(c *gin.Context) { + c.Request.Header.Set("Origin", "https://example.com") + PasskeyRegisterBegin(c) + }) + var result securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + assert.False(t, result.Success) + assert.Equal(t, "PASSKEY_RP_ID_UNAVAILABLE", result.Code) + var count int64 + require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposePasskeyRegister).Count(&count).Error) + assert.Zero(t, count) +} + +func TestPasskeyRPIDRotationRetainsDefaultsAndRollsBackOnFailure(t *testing.T) { + setupSecurityEnrollmentTest(t) + setupPasskeyDomainOptions(t) + settings := system_setting.GetPasskeySettings() + settings.RPID, settings.LegacyRPIDs = "", "" + settings.Origins = "https://www.example.com,https://example.com,https://login.example.com" + system_setting.ServerAddress = "https://www.example.com" + require.NoError(t, model.DB.Create(&model.Option{Key: "passkey.rp_id", Value: ""}).Error) + require.NoError(t, model.UpdateOption("passkey.rp_id", "example.com")) + assert.Equal(t, []string{"example.com", "www.example.com"}, system_setting.PasskeySettingsSnapshot().RelyingPartyIDs()) + // Simulate a second node with an older local cache. Persisted domains win. + system_setting.GetPasskeySettings().RPID = "www.example.com" + require.NoError(t, model.UpdateOption("passkey.rp_id", "login.example.com")) + assert.Equal(t, []string{"login.example.com", "www.example.com", "example.com"}, system_setting.PasskeySettingsSnapshot().RelyingPartyIDs()) + before, err := model.AllOption() + require.NoError(t, err) + snapshot := system_setting.PasskeySettingsSnapshot() + failure := errors.New("synthetic option write failure") + require.NoError(t, model.DB.Callback().Update().Before("gorm:update").Register("passkey_option_failure", func(tx *gorm.DB) { + if option, ok := tx.Statement.Dest.(*model.Option); ok && option.Key == "passkey.legacy_rp_ids" { + tx.AddError(failure) + } + })) + assert.ErrorIs(t, model.UpdateOptionsBulk(map[string]string{"passkey.rp_id": "next.example.com", "passkey.legacy_rp_ids": "www.example.com"}), failure) + require.NoError(t, model.DB.Callback().Update().Remove("passkey_option_failure")) + after, err := model.AllOption() + require.NoError(t, err) + assert.ElementsMatch(t, before, after) + assert.Equal(t, snapshot, system_setting.PasskeySettingsSnapshot()) + for _, invalid := range []string{"*.example.com", "https://example.com", "example.com:443", "example.com/path", "com"} { + assert.ErrorIs(t, model.UpdateOption("passkey.rp_id", invalid), system_setting.ErrPasskeyRPIDInvalid) + assert.ErrorIs(t, model.UpdateOption("passkey.legacy_rp_ids", invalid), system_setting.ErrPasskeyRPIDInvalid) + } + assert.Equal(t, snapshot, system_setting.PasskeySettingsSnapshot()) + // Older settings accepted mixed case. Rotation must preserve its hash input. + require.NoError(t, model.DB.Model(&model.Option{}).Where(&model.Option{Key: "passkey.rp_id"}).Update("value", "WWW.example.com").Error) + require.NoError(t, model.UpdateOption("passkey.rp_id", "next.example.com")) + assert.Contains(t, system_setting.PasskeySettingsSnapshot().RelyingPartyIDs(), "WWW.example.com") +} + +func TestPasskeyRPIDRotationKeepsInFlightRegistration(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + setupPasskeyDomainOptions(t) + system_setting.GetPasskeySettings().Origins = "https://example.com,https://www.example.com" + proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: "passkey.register"}, "password") + begin := decodePasskeyDomainBegin(t, securityEnrollmentRequest(http.MethodPost, "/api/user/passkey/register/begin", "", proof, identity, PasskeyRegisterBegin)) + assert.Equal(t, "example.com", begin.Options.PublicKey.RP.ID) + require.NoError(t, model.UpdateOption("passkey.rp_id", "www.example.com")) + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + response := passkeyDomainRequest(t, "/api/user/passkey/register/finish", passkeyFinishRequest{FlowToken: begin.FlowToken, Credential: securityPasskeyResponse(t, key, begin.Options.PublicKey.Challenge, true, 0)}, identity, "https://example.com", PasskeyRegisterFinish) + var result securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + require.True(t, result.Success, response.Body.String()) + credential, err := model.GetPasskeyByUserID(user.Id) + require.NoError(t, err) + require.NotNil(t, credential.RPID) + assert.Equal(t, "example.com", *credential.RPID) + // A later registration uses the current primary, not the retained domain. + wa, err := passkeysvc.BuildWebAuthn(httptest.NewRequest(http.MethodPost, "https://www.example.com/api/user/passkey/register/begin", nil)) + require.NoError(t, err) + options, _, err := wa.BeginRegistration(passkeysvc.NewWebAuthnUser(user, credential)) + require.NoError(t, err) + assert.Equal(t, "www.example.com", options.Response.RelyingParty.ID) +} + +func TestPasskeyDomainFailuresDoNotLogCredentials(t *testing.T) { + gin.SetMode(gin.TestMode) + var output bytes.Buffer + common.LogWriterMu.Lock() + previousWriter := gin.DefaultErrorWriter + gin.DefaultErrorWriter = &output + common.LogWriterMu.Unlock() + t.Cleanup(func() { + common.LogWriterMu.Lock() + gin.DefaultErrorWriter = previousWriter + common.LogWriterMu.Unlock() + }) + response := securityEnrollmentRequest(http.MethodPost, "/api/user/passkey/login/finish", "", "", service.AuthIdentity{}, func(c *gin.Context) { + c.Set("passkey_rp_id", "www.example.com") + writeSecurityOperationError(c, protocol.ErrVerification.WithDetails("private-flow-token").WithInfo("private-challenge")) + }) + assert.Contains(t, response.Body.String(), "SECURITY_VERIFICATION_FAILED") + assert.Contains(t, output.String(), "verification_error") + assert.Contains(t, output.String(), "www.example.com") + for _, secret := range []string{"private-flow-token", "private-challenge"} { + assert.NotContains(t, response.Body.String(), secret) + assert.NotContains(t, output.String(), secret) + } +} + +func TestPasskeyDomainErrorsRespectRequestLanguage(t *testing.T) { + user, _ := setupSecurityEnrollmentTest(t) + setupPasskeyDomainOptions(t) + system_setting.GetPasskeySettings().LegacyRPIDs = "www.example.com" + require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, CredentialID: "unknown-domain", PublicKey: "key"}).Error) + for _, locale := range []struct { + language, invalid, unavailable, removal string + }{ + {"zh-CN", "通行密钥域名无效。请填写域名,不包含协议、端口、路径或通配符。", "此通行密钥域名无法在当前网站使用。请前往原网站或选择其他验证方式。", "请核对受影响的通行密钥并确认删除域名。配置或影响范围发生变化后,需要重新确认。"}, + {"zh-TW", "通行金鑰網域無效。請填寫網域,不包含通訊協定、連接埠、路徑或萬用字元。", "此通行金鑰網域無法在目前網站使用。請前往原網站或選擇其他驗證方式。", "請核對受影響的通行金鑰並確認刪除網域。設定或影響範圍變更後,需要重新確認。"}, + {"en", "Invalid Passkey domain. Enter a domain without a scheme, port, path or wildcard.", "This Passkey domain is not available on this website. Use its original website or another verification method.", "Review the affected Passkeys and confirm the domain removal. If the settings or impact have changed, confirmation is required again."}, + } { + t.Run(locale.language, func(t *testing.T) { + for _, request := range []struct { + path, body, code, message string + handler gin.HandlerFunc + }{ + {"/api/option/", `{"key":"passkey.rp_id","value":"localhost:3000"}`, "PASSKEY_RP_ID_INVALID", locale.invalid, UpdateOption}, + {"/api/option/", `{"key":"passkey.legacy_rp_ids","value":"localhost:3001"}`, "PASSKEY_RP_ID_INVALID", locale.invalid, UpdateOption}, + {"/api/option/", `{"key":"passkey.legacy_rp_ids","value":""}`, "PASSKEY_RP_ID_REMOVAL_CONFIRMATION_REQUIRED", locale.removal, UpdateOption}, + {"/api/user/passkey/login/begin", `{"rp_id":"unconfigured.example.com"}`, "PASSKEY_RP_ID_UNAVAILABLE", locale.unavailable, PasskeyLoginBegin}, + } { + response := httptest.NewRecorder() + c, _ := gin.CreateTestContext(response) + method := http.MethodPost + if request.path == "/api/option/" { + method = http.MethodPut + } + c.Request = httptest.NewRequest(method, request.path, strings.NewReader(request.body)) + c.Request.Header.Set("Accept-Language", locale.language) + c.Request.Header.Set("Origin", "https://example.com") + c.Set("id", user.Id) + c.Set("role", common.RoleRootUser) + request.handler(c) + var result securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + expectedStatus := http.StatusOK + if request.code == "PASSKEY_RP_ID_REMOVAL_CONFIRMATION_REQUIRED" { + expectedStatus = http.StatusConflict + } + assert.Equal(t, expectedStatus, response.Code) + assert.False(t, result.Success) + assert.Equal(t, request.code, result.Code) + assert.Equal(t, request.message, result.Message) + } + }) + } +} + +func TestPasskeyRPIDMigrationPreservesExistingCredentials(t *testing.T) { + for _, upgrade := range []bool{false, true} { + t.Run(fmt.Sprintf("upgrade=%t", upgrade), func(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + key := newSecurityLoginPasskey(t, user.Id) + before, err := model.GetPasskeyByUserID(user.Id) + require.NoError(t, err) + if upgrade { + // This schema is copied from the latest released model at + // v1.0.0-rc.36, with the same GORM and database driver versions. + require.NoError(t, model.DB.Migrator().DropTable(&model.PasskeyCredential{})) + require.NoError(t, model.DB.AutoMigrate(&passkeyCredentialBeforeRPID{})) + encoded, err := common.Marshal(before) + require.NoError(t, err) + var legacy passkeyCredentialBeforeRPID + require.NoError(t, common.Unmarshal(encoded, &legacy)) + require.NoError(t, model.DB.Create(&legacy).Error) + } + pool, err := model.DB.DB() + require.NoError(t, err) + for range 2 { + // Each migration represents a new application startup. Discard + // PostgreSQL's old SELECT * prepared plans along with its old pool. + pool.SetMaxIdleConns(0) + pool.SetMaxIdleConns(2) + require.NoError(t, model.DB.AutoMigrate(&model.PasskeyCredential{})) + } + after, err := model.GetPasskeyByUserID(user.Id) + require.NoError(t, err) + assert.Equal(t, before, after) + assert.True(t, model.DB.Migrator().HasIndex(&model.PasskeyCredential{}, "idx_passkey_credentials_deleted_at")) + assert.Error(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, CredentialID: "another-key", PublicKey: "key"}).Error) + assert.Error(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id + 1, CredentialID: before.CredentialID, PublicKey: "key"}).Error) + system_setting.GetPasskeySettings().LegacyRPIDs = "www.example.com" + system_setting.GetPasskeySettings().Origins = "https://example.com,https://www.example.com" + begin := decodePasskeyDomainBegin(t, passkeyDomainRequest(t, "/api/user/passkey/login/begin", map[string]string{"rp_id": "www.example.com"}, identity, "https://www.example.com", PasskeyLoginBegin)) + response := passkeyDomainRequest(t, "/api/user/passkey/login/finish", map[string]any{"flow_token": begin.FlowToken, "credential": passkeyDomainAssertion(t, key, begin.Options.PublicKey.Challenge, "www.example.com", "https://www.example.com", user.Id, true)}, identity, "https://www.example.com", PasskeyLoginFinish) + var result securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + assert.True(t, result.Success, response.Body.String()) + }) + } +} + func (*passkeyTestBody) Close() error { return nil } func TestParsePasskeyFinishRequestDoesNotRewriteRequestBody(t *testing.T) { @@ -70,3 +763,26 @@ func TestPasskeyRegisterFinishRejectsUnapprovedFlowWithoutConsumingIt(t *testing require.NoError(t, err) assert.Nil(t, flow.ConsumedAt) } + +type passkeyCredentialBeforeRPID struct { + ID int `json:"id" gorm:"primaryKey"` + UserID int `json:"user_id" gorm:"uniqueIndex;not null"` + CredentialID string `json:"credential_id" gorm:"type:varchar(512);uniqueIndex;not null"` // base64 encoded + PublicKey string `json:"public_key" gorm:"type:text;not null"` // base64 encoded + AttestationType string `json:"attestation_type" gorm:"type:varchar(255)"` + AAGUID string `json:"aaguid" gorm:"type:varchar(512)"` // base64 encoded + SignCount uint32 `json:"sign_count" gorm:"default:0"` + CloneWarning bool `json:"clone_warning"` + UserPresent bool `json:"user_present"` + UserVerified bool `json:"user_verified"` + BackupEligible bool `json:"backup_eligible"` + BackupState bool `json:"backup_state"` + Transports string `json:"transports" gorm:"type:text"` + Attachment string `json:"attachment" gorm:"type:varchar(32)"` + LastUsedAt *time.Time `json:"last_used_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +func (passkeyCredentialBeforeRPID) TableName() string { return "passkey_credentials" } diff --git a/controller/secure_verification.go b/controller/secure_verification.go index 06dbd6b88..69c1aaf26 100644 --- a/controller/secure_verification.go +++ b/controller/secure_verification.go @@ -3,12 +3,18 @@ package controller import ( "errors" "net/http" + "slices" + "strings" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/i18n" + "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/oauth" "github.com/QuantumNous/new-api/service" + passkeysvc "github.com/QuantumNous/new-api/service/passkey" + "github.com/QuantumNous/new-api/setting/system_setting" "github.com/gin-gonic/gin" "github.com/go-webauthn/webauthn/protocol" ) @@ -34,6 +40,10 @@ func writeSecurityOperationError(c *gin.Context, err error) { var code, message string var protocolError *protocol.Error switch { + case errors.Is(err, passkeysvc.ErrRPIDUnavailable): + code, message = "PASSKEY_RP_ID_UNAVAILABLE", i18n.T(c, i18n.MsgPasskeyRPIDUnavailable) + case errors.Is(err, system_setting.ErrPasskeyRPIDInvalid): + code, message = "PASSKEY_RP_ID_INVALID", i18n.T(c, i18n.MsgPasskeyRPIDInvalid) case errors.Is(err, service.ErrAccountEmailInvalid), errors.Is(err, service.ErrAccountEmailRestricted): code, message = "EMAIL_ADDRESS_REJECTED", err.Error() case errors.Is(err, model.ErrEmailAlreadyTaken): @@ -112,6 +122,15 @@ func writeSecurityOperationError(c *gin.Context, err error) { return } c.Set("security_error_code", code) + if strings.Contains(c.Request.URL.Path, "/passkey/") { + reason := "verification_failed" + if errors.As(err, &protocolError) && slices.Contains([]string{"invalid_request", "challenge_mismatch", "parse_error", "auth_data", "verification_error", "invalid_signature", "invalid_key_type", "unsupported_key_algorithm"}, protocolError.Type) { + reason = protocolError.Type + } + // Protocol details can contain challenges and client-controlled data. + // Only fixed categories and the server-selected public RP ID are logged. + logger.LogWarn(c.Request.Context(), "passkey verification rejected: code=%s reason=%s rp_id=%q", code, reason, c.GetString("passkey_rp_id")) + } c.JSON(status, gin.H{"success": false, "code": code, "message": message}) } diff --git a/controller/security_enrollment_test.go b/controller/security_enrollment_test.go index 1df088c0d..e0901ea86 100644 --- a/controller/security_enrollment_test.go +++ b/controller/security_enrollment_test.go @@ -64,7 +64,7 @@ func setupSecurityEnrollmentTest(t *testing.T) (*model.User, service.AuthIdentit var version string require.NoError(t, db.Raw(versionQuery).Scan(&version).Error) t.Logf("database: %s %s", dialect, version) - require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.TwoFA{}, &model.TwoFABackupCode{}, &model.PasskeyCredential{}, &model.AuthFlow{}, &model.UserOAuthBinding{})) + require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.TwoFA{}, &model.TwoFABackupCode{}, &model.PasskeyCredential{}, &model.AuthFlow{}, &model.UserOAuthBinding{}, &model.Option{})) require.NoError(t, logDB.AutoMigrate(&model.AuditLog{})) model.DB, model.LOG_DB = db, logDB dbType := common.DatabaseTypeSQLite diff --git a/i18n/keys.go b/i18n/keys.go index c0948c1a2..98bac83d7 100644 --- a/i18n/keys.go +++ b/i18n/keys.go @@ -221,11 +221,14 @@ const ( // Passkey related messages const ( - MsgPasskeyCreateFailed = "passkey.create_failed" - MsgPasskeyLoginAbnormal = "passkey.login_abnormal" - MsgPasskeyUpdateFailed = "passkey.update_failed" - MsgPasskeyInvalidUserId = "passkey.invalid_user_id" - MsgPasskeyVerifyFailed = "passkey.verify_failed" + MsgPasskeyCreateFailed = "passkey.create_failed" + MsgPasskeyLoginAbnormal = "passkey.login_abnormal" + MsgPasskeyUpdateFailed = "passkey.update_failed" + MsgPasskeyInvalidUserId = "passkey.invalid_user_id" + MsgPasskeyVerifyFailed = "passkey.verify_failed" + MsgPasskeyRPIDInvalid = "passkey.rp_id_invalid" + MsgPasskeyRPIDUnavailable = "passkey.rp_id_unavailable" + MsgPasskeyRPIDRemovalConfirmation = "passkey.rp_id_removal_confirmation" ) // 2FA related messages diff --git a/i18n/locales/en.yaml b/i18n/locales/en.yaml index c5aa99091..3f2a6bc0f 100644 --- a/i18n/locales/en.yaml +++ b/i18n/locales/en.yaml @@ -195,6 +195,9 @@ passkey.login_abnormal: "Passkey login status is abnormal" passkey.update_failed: "Passkey credential update failed" passkey.invalid_user_id: "Invalid user ID" passkey.verify_failed: "Passkey verification failed, please try again or contact administrator" +passkey.rp_id_invalid: "Invalid Passkey domain. Enter a domain without a scheme, port, path or wildcard." +passkey.rp_id_unavailable: "This Passkey domain is not available on this website. Use its original website or another verification method." +passkey.rp_id_removal_confirmation: "Review the affected Passkeys and confirm the domain removal. If the settings or impact have changed, confirmation is required again." # 2FA messages twofa.not_enabled: "User has not enabled 2FA" diff --git a/i18n/locales/zh-CN.yaml b/i18n/locales/zh-CN.yaml index 646959eb1..574fe36e1 100644 --- a/i18n/locales/zh-CN.yaml +++ b/i18n/locales/zh-CN.yaml @@ -196,6 +196,9 @@ passkey.login_abnormal: "Passkey 登录状态异常" passkey.update_failed: "Passkey 凭证更新失败" passkey.invalid_user_id: "无效的用户 ID" passkey.verify_failed: "Passkey 验证失败,请重试或联系管理员" +passkey.rp_id_invalid: "通行密钥域名无效。请填写域名,不包含协议、端口、路径或通配符。" +passkey.rp_id_unavailable: "此通行密钥域名无法在当前网站使用。请前往原网站或选择其他验证方式。" +passkey.rp_id_removal_confirmation: "请核对受影响的通行密钥并确认删除域名。配置或影响范围发生变化后,需要重新确认。" # 2FA messages twofa.not_enabled: "用户未启用2FA" diff --git a/i18n/locales/zh-TW.yaml b/i18n/locales/zh-TW.yaml index 1a1b38c26..901611eef 100644 --- a/i18n/locales/zh-TW.yaml +++ b/i18n/locales/zh-TW.yaml @@ -196,6 +196,9 @@ passkey.login_abnormal: "Passkey 登錄狀態異常" passkey.update_failed: "Passkey 憑證更新失敗" passkey.invalid_user_id: "無效的使用者 ID" passkey.verify_failed: "Passkey 驗證失敗,請重試或聯繫管理員" +passkey.rp_id_invalid: "通行金鑰網域無效。請填寫網域,不包含通訊協定、連接埠、路徑或萬用字元。" +passkey.rp_id_unavailable: "此通行金鑰網域無法在目前網站使用。請前往原網站或選擇其他驗證方式。" +passkey.rp_id_removal_confirmation: "請核對受影響的通行金鑰並確認刪除網域。設定或影響範圍變更後,需要重新確認。" # 2FA messages twofa.not_enabled: "使用者未啟用2FA" diff --git a/model/option.go b/model/option.go index 1852d4404..24f0fd638 100644 --- a/model/option.go +++ b/model/option.go @@ -195,13 +195,21 @@ func InitOptionMap() { } func loadOptionsFromDatabase() { + passkeyOptionMutex.Lock() + defer passkeyOptionMutex.Unlock() options, _ := AllOption() + passkeyOptions := make(map[string]string) for _, option := range options { + if IsPasskeyDomainOption(option.Key) { + passkeyOptions[option.Key] = option.Value + continue + } err := updateOptionMap(option.Key, option.Value) if err != nil { common.SysLog("failed to update option map: " + err.Error()) } } + applyPasskeyDomainOptions(passkeyOptions) } func SyncOptions(frequency int) { @@ -226,6 +234,10 @@ func validateOptionValue(key string, value string) error { } func UpdateOption(key string, value string) error { + if IsPasskeyDomainOption(key) { + _, err := UpdatePasskeyDomainOptions(map[string]string{key: value}, false, "") + return err + } if IsModelPricingOption(key) { return UpdateModelPricingOptions(map[string]string{key: value}) } @@ -256,6 +268,12 @@ func UpdateOptionsBulk(values map[string]string) error { if len(values) == 0 { return nil } + for key := range values { + if IsPasskeyDomainOption(key) { + _, err := UpdatePasskeyDomainOptions(values, false, "") + return err + } + } for key, value := range values { if err := validateOptionValue(key, value); err != nil { return err diff --git a/model/passkey.go b/model/passkey.go index b38cbda29..b171b75d6 100644 --- a/model/passkey.go +++ b/model/passkey.go @@ -8,6 +8,7 @@ import ( "time" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/setting/system_setting" "github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/webauthn" @@ -22,6 +23,7 @@ var ( type PasskeyCredential struct { ID int `json:"id" gorm:"primaryKey"` UserID int `json:"user_id" gorm:"uniqueIndex;not null"` + RPID *string `json:"rp_id,omitempty" gorm:"column:rp_id;type:varchar(253)"` CredentialID string `json:"credential_id" gorm:"type:varchar(512);uniqueIndex;not null"` // base64 encoded PublicKey string `json:"public_key" gorm:"type:text;not null"` // base64 encoded AttestationType string `json:"attestation_type" gorm:"type:varchar(255)"` @@ -161,29 +163,49 @@ func GetPasskeyByCredentialID(credentialID []byte) (*PasskeyCredential, error) { // UpdatePasskeyAssertionState persists only fields produced by a successful // assertion. Registration identity (credential ID, public key, AAGUID, // transports and attestation metadata) is immutable on this path. -func UpdatePasskeyAssertionState(userID int, credential *webauthn.Credential, lastUsedAt time.Time) error { - if userID <= 0 || credential == nil || len(credential.ID) == 0 || lastUsedAt.IsZero() { +func UpdatePasskeyAssertionState(userID int, credential *webauthn.Credential, lastUsedAt time.Time, rpID string) error { + if userID <= 0 || credential == nil || len(credential.ID) == 0 || lastUsedAt.IsZero() || rpID == "" { return fmt.Errorf("Passkey 保存失败,请重试") } credentialID := base64.StdEncoding.EncodeToString(credential.ID) - result := DB.Model(&PasskeyCredential{}). - Where("user_id = ? AND credential_id = ?", userID, credentialID). - Updates(map[string]any{ - "sign_count": credential.Authenticator.SignCount, - "clone_warning": credential.Authenticator.CloneWarning, - "user_present": credential.Flags.UserPresent, - "user_verified": credential.Flags.UserVerified, - "backup_eligible": credential.Flags.BackupEligible, - "backup_state": credential.Flags.BackupState, - "last_used_at": lastUsedAt, - }) - if result.Error != nil { - return result.Error - } - if result.RowsAffected != 1 { - return ErrPasskeyNotFound - } - return nil + passkeyOptionMutex.Lock() + defer passkeyOptionMutex.Unlock() + return DB.Transaction(func(tx *gorm.DB) error { + if err := validatePasskeyRPIDWithTx(tx, rpID); err != nil { + return err + } + var stored PasskeyCredential + if err := lockForUpdate(tx).Where("user_id = ? AND credential_id = ?", userID, credentialID).First(&stored).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrPasskeyNotFound + } + return err + } + // Compare in Go: the database collation may fold historical case. + if stored.RPID != nil && *stored.RPID != "" && *stored.RPID != rpID { + return system_setting.ErrPasskeyRPIDUnavailable + } + result := tx.Model(&PasskeyCredential{}). + Where("user_id = ? AND credential_id = ?", userID, credentialID). + Where("rp_id IS NULL OR rp_id = ? OR rp_id = ?", "", rpID). + Updates(map[string]any{ + "rp_id": rpID, + "sign_count": credential.Authenticator.SignCount, + "clone_warning": credential.Authenticator.CloneWarning, + "user_present": credential.Flags.UserPresent, + "user_verified": credential.Flags.UserVerified, + "backup_eligible": credential.Flags.BackupEligible, + "backup_state": credential.Flags.BackupState, + "last_used_at": lastUsedAt, + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrPasskeyNotFound + } + return nil + }) } func upsertPasskeyCredentialWithTx(tx *gorm.DB, credential *PasskeyCredential) error { @@ -212,7 +234,17 @@ func upsertPasskeyCredentialWithAuthVersion(credential *PasskeyCredential, ident if credential == nil || credential.UserID <= 0 { return fmt.Errorf("Passkey 保存失败,请重试") } + passkeyOptionMutex.Lock() + defer passkeyOptionMutex.Unlock() if err := DB.Transaction(func(tx *gorm.DB) error { + if identity != nil && (credential.RPID == nil || *credential.RPID == "") { + return system_setting.ErrPasskeyRPIDUnavailable + } + if credential.RPID != nil && *credential.RPID != "" { + if err := validatePasskeyRPIDWithTx(tx, *credential.RPID); err != nil { + return err + } + } if identity != nil { if identity.UserID != credential.UserID { return ErrUserSessionInactive diff --git a/model/passkey_option.go b/model/passkey_option.go new file mode 100644 index 000000000..09a580f4e --- /dev/null +++ b/model/passkey_option.go @@ -0,0 +1,270 @@ +package model + +import ( + "crypto/hmac" + "errors" + "maps" + "slices" + "strings" + "sync" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/setting/system_setting" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var passkeyOptionMutex sync.Mutex +var errPasskeyDomainPreview = errors.New("passkey domain preview") +var ErrPasskeyDomainRemovalConfirmation = errors.New("Review the affected Passkeys and confirm the domain removal.") + +// PasskeyDomainChange is both the preview and the authoritative saved result. +// Unknown credentials may belong to a removed domain; they are not a known loss. +type PasskeyDomainChange struct { + RPID string `json:"rp_id"` + LegacyRPIDs string `json:"legacy_rp_ids"` + Origins string `json:"origins"` + PreviousRPID string `json:"previous_rp_id"` + EffectiveRPID string `json:"effective_rp_id"` + RemovedRPIDs []string `json:"removed_rp_ids"` + AffectedCredentials int64 `json:"affected_credentials"` + UnknownCredentials int64 `json:"unknown_credentials"` + ConfirmationRequired bool `json:"confirmation_required"` + RemovalConfirmation string `json:"removal_confirmation"` +} + +type PasskeyDomainRemovalError struct { + Change *PasskeyDomainChange +} + +func (e *PasskeyDomainRemovalError) Error() string { + return ErrPasskeyDomainRemovalConfirmation.Error() +} +func (e *PasskeyDomainRemovalError) Unwrap() error { return ErrPasskeyDomainRemovalConfirmation } + +// ServerAddress and origins participate because either can change an implicit RP ID. +func IsPasskeyDomainOption(key string) bool { + return key == "passkey.rp_id" || key == "passkey.legacy_rp_ids" || key == "passkey.origins" || key == "ServerAddress" +} + +// lockPasskeyDomainSettings serializes trust changes and credential writes across +// nodes. Callers also hold passkeyOptionMutex for SQLite and local cache ordering. +// Always acquire these option rows before any user/session/credential row locks. +func lockPasskeyDomainSettings(tx *gorm.DB) (system_setting.PasskeySettings, string, error) { + common.OptionMapRWMutex.RLock() + settings, serverAddress := *system_setting.GetPasskeySettings(), system_setting.ServerAddress + common.OptionMapRWMutex.RUnlock() + rows := []Option{ + {Key: "ServerAddress", Value: serverAddress}, + {Key: "passkey.legacy_rp_ids", Value: settings.LegacyRPIDs}, + {Key: "passkey.origins", Value: settings.Origins}, + {Key: "passkey.rp_id", Value: settings.RPID}, + } + // The first write also acquires SQLite's writer lock before any reads. + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&rows).Error; err != nil { + return settings, serverAddress, err + } + for i := range rows { + if err := lockForUpdate(tx).Where(&Option{Key: rows[i].Key}).First(&rows[i]).Error; err != nil { + return settings, serverAddress, err + } + } + serverAddress = rows[0].Value + settings.LegacyRPIDs, settings.Origins, settings.RPID = rows[1].Value, rows[2].Value, rows[3].Value + return settings, serverAddress, nil +} + +func validatePasskeyRPIDWithTx(tx *gorm.DB, rpID string) error { + settings, serverAddress, err := lockPasskeyDomainSettings(tx) + if err != nil { + return err + } + // Preserve the existing request-derived default only when no domain or + // origin has ever been configured. Finish still checks the signed origin. + if settings.RPID == "" && settings.LegacyRPIDs == "" && settings.Origins == "" && serverAddress == "" { + return nil + } + if !slices.Contains(settings.WithDefaults(serverAddress).RelyingPartyIDs(), rpID) { + return system_setting.ErrPasskeyRPIDUnavailable + } + return nil +} + +// UpdatePasskeyDomainOptions keeps every supplied option in one transaction, +// including unrelated keys from UpdateOptionsBulk. Preview rolls back even the +// initial default rows and never publishes a local configuration change. +func UpdatePasskeyDomainOptions(values map[string]string, preview bool, confirmation string) (*PasskeyDomainChange, error) { + passkeyOptionMutex.Lock() + defer passkeyOptionMutex.Unlock() + values = maps.Clone(values) + for key, value := range values { + if err := validateOptionValue(key, value); err != nil { + return nil, err + } + } + var change *PasskeyDomainChange + err := DB.Transaction(func(tx *gorm.DB) error { + settings, serverAddress, err := lockPasskeyDomainSettings(tx) + if err != nil { + return err + } + previous := settings.WithDefaults(serverAddress) + before := [4]string{serverAddress, settings.RPID, settings.LegacyRPIDs, settings.Origins} + if value, changed := values["ServerAddress"]; changed { + serverAddress = value + } + if value, changed := values["passkey.origins"]; changed { + origins := []string{} + for _, origin := range strings.FieldsFunc(value, func(c rune) bool { return c == ',' || c == '\n' || c == '\r' }) { + origin = strings.TrimSpace(origin) + if origin != "" && !slices.Contains(origins, origin) { + origins = append(origins, origin) + } + } + settings.Origins = strings.Join(origins, ",") + } else if before[0] != serverAddress && (settings.Origins == "" || settings.Origins == "[]") { + // Retain the previously trusted implicit website as well as the new + // default, so retaining its RP ID does not leave its keys unusable. + origins := []string{} + for _, origin := range []string{previous.Origins, settings.WithDefaults(serverAddress).Origins} { + if origin != "" && !slices.Contains(origins, origin) { + origins = append(origins, origin) + } + } + settings.Origins = strings.Join(origins, ",") + } + if value, changed := values["passkey.rp_id"]; changed { + settings.RPID = strings.TrimSpace(value) + if settings.RPID != "" { + settings.RPID, err = system_setting.NormalizePasskeyRPID(settings.RPID, settings.WithDefaults(serverAddress).Origins) + if err != nil { + return err + } + } + } + if value, changed := values["passkey.legacy_rp_ids"]; changed { + settings.LegacyRPIDs = value + } + effective := settings.WithDefaults(serverAddress) + legacy, err := system_setting.ParsePasskeyRPIDs(settings.LegacyRPIDs, effective.Origins) + if err != nil { + return err + } + oldRPID, nextRPID := previous.EffectiveRPID(), effective.EffectiveRPID() + _, previousDomainError := system_setting.NormalizePasskeyRPID(oldRPID, previous.Origins) + if previousDomainError == nil && oldRPID != nextRPID && !slices.Contains(legacy, oldRPID) { + legacy = append(legacy, oldRPID) + } + settings.LegacyRPIDs = strings.Join(legacy, ",") + // Validate retained single-label domains against the complete proposed + // origin set too. Do not normalize their historical hash inputs. + if _, err := system_setting.ParsePasskeyRPIDs(settings.LegacyRPIDs, effective.Origins); err != nil { + return err + } + // ServerAddress also supports non-WebAuthn deployments (for example an + // IP during initial setup). Restrict explicit RP IDs, not that URL. + if settings.RPID != "" { + if _, err := system_setting.NormalizePasskeyRPID(nextRPID, effective.Origins); err != nil { + return err + } + } + change = &PasskeyDomainChange{ + RPID: settings.RPID, LegacyRPIDs: settings.LegacyRPIDs, Origins: settings.Origins, + PreviousRPID: oldRPID, EffectiveRPID: nextRPID, RemovedRPIDs: []string{}, + } + nextIDs := settings.WithDefaults(serverAddress).RelyingPartyIDs() + for _, id := range previous.RelyingPartyIDs() { + if !slices.Contains(nextIDs, id) { + change.RemovedRPIDs = append(change.RemovedRPIDs, id) + } + } + if len(change.RemovedRPIDs) > 0 { + // MySQL may compare/group text case-insensitively. Select only + // candidate RP values, then count exact bytes without credential IDs. + rows, err := tx.Model(&PasskeyCredential{}).Select("rp_id"). + Where("rp_id IS NULL OR rp_id = ? OR rp_id IN ?", "", change.RemovedRPIDs).Rows() + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var rpID *string + if err := rows.Scan(&rpID); err != nil { + return err + } + if rpID == nil || *rpID == "" { + change.UnknownCredentials++ + } else if slices.Contains(change.RemovedRPIDs, *rpID) { + change.AffectedCredentials++ + } + } + if err := rows.Err(); err != nil { + return err + } + if err := rows.Close(); err != nil { + return err + } + } + change.ConfirmationRequired = change.AffectedCredentials > 0 || change.UnknownCredentials > 0 + // This is an intent/freshness binding, not an authentication credential. + // Root authorization remains mandatory. No map iteration affects its hash. + payload, err := common.Marshal(struct { + Before [4]string + After [4]string + Removed []string + Known, Unknown int64 + }{before, [4]string{serverAddress, settings.RPID, settings.LegacyRPIDs, settings.Origins}, change.RemovedRPIDs, change.AffectedCredentials, change.UnknownCredentials}) + if err != nil { + return err + } + change.RemovalConfirmation = common.GenerateHMACWithKey([]byte("passkey-domains-v1:"+common.SessionSecret), string(payload)) + if preview { + return errPasskeyDomainPreview + } + if (change.ConfirmationRequired || confirmation != "") && !hmac.Equal([]byte(confirmation), []byte(change.RemovalConfirmation)) { + return &PasskeyDomainRemovalError{Change: change} + } + values["passkey.rp_id"], values["passkey.legacy_rp_ids"], values["passkey.origins"] = settings.RPID, settings.LegacyRPIDs, settings.Origins + values["ServerAddress"] = serverAddress + keys := slices.Sorted(maps.Keys(values)) + for _, key := range keys { + if err := tx.Save(&Option{Key: key, Value: values[key]}).Error; err != nil { + return err + } + } + return nil + }) + if errors.Is(err, errPasskeyDomainPreview) { + return change, nil + } + if err != nil { + return change, err + } + applyPasskeyDomainOptions(values) + for key, value := range values { + if !IsPasskeyDomainOption(key) { + if err := updateOptionMap(key, value); err != nil { + return change, err + } + } + } + return change, nil +} + +func applyPasskeyDomainOptions(values map[string]string) { + common.OptionMapRWMutex.Lock() + defer common.OptionMapRWMutex.Unlock() + if common.OptionMap == nil { + common.OptionMap = make(map[string]string) + } + for _, key := range []string{"ServerAddress", "passkey.legacy_rp_ids", "passkey.origins", "passkey.rp_id"} { + if value, ok := values[key]; ok { + common.OptionMap[key] = value + if key == "ServerAddress" { + system_setting.ServerAddress = value + } else { + handleConfigUpdate(key, value) + } + } + } +} diff --git a/model/user_authentication_test.go b/model/user_authentication_test.go index 6f5149969..fdf6700d8 100644 --- a/model/user_authentication_test.go +++ b/model/user_authentication_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/setting/system_setting" "github.com/go-redis/redis/v8" "github.com/go-webauthn/webauthn/webauthn" "github.com/pquerna/otp/totp" @@ -260,6 +261,22 @@ func TestSecurityFactorMutationsAdvanceUserAuthVersion(t *testing.T) { func TestUpdatePasskeyAssertionStateCannotRewriteRegistrationIdentity(t *testing.T) { truncateTables(t) + require.NoError(t, DB.AutoMigrate(&Option{})) + previousSettings := *system_setting.GetPasskeySettings() + domainKeys := map[string]any{"key": []string{"ServerAddress", "passkey.rp_id", "passkey.legacy_rp_ids", "passkey.origins"}} + var previousOptions []Option + require.NoError(t, DB.Where(domainKeys).Find(&previousOptions).Error) + t.Cleanup(func() { + *system_setting.GetPasskeySettings() = previousSettings + require.NoError(t, DB.Where(domainKeys).Delete(&Option{}).Error) + if len(previousOptions) > 0 { + require.NoError(t, DB.Create(&previousOptions).Error) + } + }) + *system_setting.GetPasskeySettings() = system_setting.PasskeySettings{RPID: "example.com", Origins: "https://example.com"} + for _, option := range []Option{{Key: "passkey.rp_id", Value: "example.com"}, {Key: "passkey.legacy_rp_ids", Value: ""}, {Key: "passkey.origins", Value: "https://example.com"}} { + require.NoError(t, DB.Save(&option).Error) + } user := User{Username: "passkey-assertion-state", Password: "password", AuthVersion: 1} require.NoError(t, DB.Create(&user).Error) @@ -292,7 +309,7 @@ func TestUpdatePasskeyAssertionStateCannotRewriteRegistrationIdentity(t *testing CloneWarning: true, }, } - require.NoError(t, UpdatePasskeyAssertionState(user.Id, validated, usedAt)) + require.NoError(t, UpdatePasskeyAssertionState(user.Id, validated, usedAt, "example.com")) var updated PasskeyCredential require.NoError(t, DB.First(&updated, stored.ID).Error) @@ -312,7 +329,7 @@ func TestUpdatePasskeyAssertionStateCannotRewriteRegistrationIdentity(t *testing assert.Equal(t, usedAt.Unix(), updated.LastUsedAt.Unix()) validated.ID = []byte("another-credential") - assert.ErrorIs(t, UpdatePasskeyAssertionState(user.Id, validated, usedAt), ErrPasskeyNotFound) + assert.ErrorIs(t, UpdatePasskeyAssertionState(user.Id, validated, usedAt, "example.com"), ErrPasskeyNotFound) } func assertUserAuthVersion(t *testing.T, userID int, expected int64) { diff --git a/router/api-router.go b/router/api-router.go index 18d37f92b..b6b80fd3e 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -205,6 +205,7 @@ func SetApiRouter(router *gin.Engine) { { optionRoute.GET("/", controller.GetOptions) optionRoute.PUT("/", controller.UpdateOption) + optionRoute.PUT("/passkey/domains", controller.UpdatePasskeyDomains) optionRoute.GET("/model_pricing", controller.GetModelPricingConfig) optionRoute.PATCH("/model_pricing", controller.UpdateModelPricingConfig) optionRoute.POST("/model_pricing/convert", controller.PreviewModelPricingConversion) diff --git a/service/account_security.go b/service/account_security.go index 9e407f3ed..436cfd396 100644 --- a/service/account_security.go +++ b/service/account_security.go @@ -13,7 +13,7 @@ import ( func UnbindAccountOAuth(identity AuthIdentity, providerID int) error { enabled := model.AccountLoginMethods{ Password: common.PasswordLoginEnabled, - Passkey: system_setting.GetPasskeySettings().Enabled, + Passkey: system_setting.PasskeySettingsSnapshot().Enabled, WeChat: common.WeChatAuthEnabled, } for _, provider := range oauth.GetAllProviders() { diff --git a/service/passkey/service.go b/service/passkey/service.go index 243eae3f6..39c020e5c 100644 --- a/service/passkey/service.go +++ b/service/passkey/service.go @@ -6,6 +6,7 @@ import ( "net" "net/http" "net/url" + "slices" "strings" "time" @@ -16,27 +17,45 @@ import ( webauthn "github.com/go-webauthn/webauthn/webauthn" ) +var ErrRPIDUnavailable = system_setting.ErrPasskeyRPIDUnavailable + // BuildWebAuthn constructs a WebAuthn instance using the current passkey settings and request context. func BuildWebAuthn(r *http.Request) (*webauthn.WebAuthn, error) { - settings := system_setting.GetPasskeySettings() - if settings == nil { - return nil, errors.New("未找到 Passkey 设置") - } + return BuildWebAuthnForRPID(r, "") +} + +// BuildWebAuthnForRPID uses a single configured RP ID, never a list of IDs to +// try against the same signed response. An empty ID is for new registrations. +func BuildWebAuthnForRPID(r *http.Request, selectedRPID string) (*webauthn.WebAuthn, error) { + settings := system_setting.PasskeySettingsSnapshot() displayName := strings.TrimSpace(settings.RPDisplayName) if displayName == "" { displayName = common.SystemName } - origins, err := resolveOrigins(r, settings) + origins, err := resolveOrigins(r, &settings) if err != nil { return nil, err } - rpID, err := resolveRPID(r, settings, origins) + rpID, err := resolveRPID(r, &settings, origins) if err != nil { return nil, err } + if selectedRPID != "" && selectedRPID != rpID { + if !slices.Contains(settings.RelyingPartyIDs(), selectedRPID) { + return nil, ErrRPIDUnavailable + } + rpID = selectedRPID + } + origins = originsForRPID(origins, rpID) + if len(origins) == 0 { + return nil, ErrRPIDUnavailable + } + if origin := r.Header.Get("Origin"); origin != "" && !protocol.IsOriginInHaystack(origin, origins) { + return nil, ErrRPIDUnavailable + } selection := protocol.AuthenticatorSelection{ ResidentKey: protocol.ResidentKeyRequirementRequired, @@ -73,6 +92,71 @@ func BuildWebAuthn(r *http.Request) (*webauthn.WebAuthn, error) { return webauthn.New(config) } +// BuildLoginWebAuthn gives a known credential's binding precedence over a +// browser's last-successful-domain hint. All choices remain server controlled. +func BuildLoginWebAuthn(r *http.Request, hint, credentialRPID string) (*webauthn.WebAuthn, []string, error) { + settings := system_setting.PasskeySettingsSnapshot() + origins, err := resolveOrigins(r, &settings) + if err != nil { + return nil, nil, err + } + primary, err := resolveRPID(r, &settings, origins) + if err != nil { + return nil, nil, err + } + configured := append([]string{primary}, settings.RelyingPartyIDs()...) + available := []string{} + for _, id := range configured { + if credentialRPID != "" && id != credentialRPID { + continue + } + allowedOrigins := originsForRPID(origins, id) + if len(allowedOrigins) == 0 || (r.Header.Get("Origin") != "" && !protocol.IsOriginInHaystack(r.Header.Get("Origin"), allowedOrigins)) { + continue + } + if !slices.Contains(available, id) { + available = append(available, id) + } + } + if len(available) == 0 { + return nil, nil, ErrRPIDUnavailable + } + selected := credentialRPID + if selected == "" { + selected = hint + } + if selected == "" { + selected = available[0] + } + if !slices.Contains(available, selected) { + return nil, nil, ErrRPIDUnavailable + } + wa, err := BuildWebAuthnForRPID(r, selected) + return wa, available, err +} + +func originsForRPID(origins []string, rpID string) []string { + allowed := []string{} + for _, origin := range origins { + parsed, err := url.Parse(origin) + if err != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.User != nil { + continue + } + host := strings.ToLower(parsed.Hostname()) + domain := strings.ToLower(rpID) + if !strings.Contains(domain, ".") && domain != "localhost" { + if host == domain && parsed.Scheme == "https" { + allowed = append(allowed, origin) + } + continue + } + if host == domain || strings.HasSuffix(host, "."+domain) { + allowed = append(allowed, origin) + } + } + return allowed +} + func resolveOrigins(r *http.Request, settings *system_setting.PasskeySettings) ([]string, error) { originsStr := strings.TrimSpace(settings.Origins) if originsStr != "" { diff --git a/service/passkey/session.go b/service/passkey/session.go index b138ba55a..c38de9873 100644 --- a/service/passkey/session.go +++ b/service/passkey/session.go @@ -72,6 +72,12 @@ func PopSessionDataFlow(token, purpose string, identity model.AuthSessionIdentit if err := common.UnmarshalJsonStr(flow.Payload, &payload); err != nil { return err } + if payload.SessionData.RelyingPartyID == "" || payload.SessionData.Expires.IsZero() { + return model.ErrAuthFlowInvalid + } + if !time.Now().Before(payload.SessionData.Expires) { + return model.ErrAuthFlowExpired + } if purpose == model.AuthFlowPurposePasskeyLogin { return nil } diff --git a/service/security_verification.go b/service/security_verification.go index b2fd984e0..3006a6784 100644 --- a/service/security_verification.go +++ b/service/security_verification.go @@ -212,7 +212,7 @@ func securityVerificationPolicy(scope string, state model.UserVerificationState) if method == VerificationMethodTwoFA && state.TwoFALocked { option.Available, option.Reason = false, ErrVerificationLocked.Error() } - if !system_setting.GetPasskeySettings().Enabled && (method == VerificationMethodPasskey || scope == VerificationScopePasskeyRegister) { + if !system_setting.PasskeySettingsSnapshot().Enabled && (method == VerificationMethodPasskey || scope == VerificationScopePasskeyRegister) { option.Available, option.Reason = false, "Passkey authentication is disabled." } options = append(options, option) diff --git a/setting/system_setting/passkey.go b/setting/system_setting/passkey.go index 41855898c..171a3351f 100644 --- a/setting/system_setting/passkey.go +++ b/setting/system_setting/passkey.go @@ -1,23 +1,138 @@ package system_setting import ( + "errors" + "net" "net/url" + "slices" "strings" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/setting/config" + "golang.org/x/net/idna" + "golang.org/x/net/publicsuffix" ) type PasskeySettings struct { Enabled bool `json:"enabled"` RPDisplayName string `json:"rp_display_name"` RPID string `json:"rp_id"` + LegacyRPIDs string `json:"legacy_rp_ids"` Origins string `json:"origins"` AllowInsecureOrigin bool `json:"allow_insecure_origin"` UserVerification string `json:"user_verification"` AttachmentPreference string `json:"attachment_preference"` } +var ErrPasskeyRPIDInvalid = errors.New("Invalid Passkey domain. Enter a domain without a scheme, port, path or wildcard.") +var ErrPasskeyRPIDUnavailable = errors.New("This Passkey domain is not available on this website. Use its original website or another verification method.") + +// PasskeySettingsSnapshot includes the effective defaults without sharing a +// mutable settings pointer with a WebAuthn ceremony. +func PasskeySettingsSnapshot() PasskeySettings { + common.OptionMapRWMutex.RLock() + settings, serverAddress := defaultPasskeySettings, ServerAddress + common.OptionMapRWMutex.RUnlock() + return settings.WithDefaults(serverAddress) +} + +// EffectiveRPID preserves the existing default and port handling for settings +// created before explicit Passkey domains were introduced. +func (s PasskeySettings) EffectiveRPID() string { + rpID := strings.TrimSpace(s.RPID) + if rpID == "" { + for origin := range strings.SplitSeq(s.Origins, ",") { + if parsed, err := url.Parse(strings.TrimSpace(origin)); err == nil && parsed.Host != "" { + rpID = parsed.Host + break + } + } + } + if host, _, err := net.SplitHostPort(rpID); err == nil { + return host + } + return rpID +} + +func NormalizePasskeyRPID(value string, configuredOrigins ...string) (string, error) { + rpID, err := idna.Lookup.ToASCII(strings.TrimSpace(value)) + rpID = strings.ToLower(rpID) + if err != nil || rpID == "" || len(rpID) > 253 || strings.ContainsAny(rpID, ":/*@?#\\") || net.ParseIP(rpID) != nil { + return "", ErrPasskeyRPIDInvalid + } + for label := range strings.SplitSeq(rpID, ".") { + if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { + return "", ErrPasskeyRPIDInvalid + } + for _, c := range label { + if c != '-' && (c < 'a' || c > 'z') && (c < '0' || c > '9') { + return "", ErrPasskeyRPIDInvalid + } + } + } + if rpID == "localhost" { + return rpID, nil + } + if _, err := publicsuffix.EffectiveTLDPlusOne(rpID); err == nil { + return rpID, nil + } + // An unknown single-label hostname is usable only at its exact, explicitly + // trusted HTTPS origin, never as a suffix of other hosts. + _, icann := publicsuffix.PublicSuffix(rpID) + if icann || strings.Contains(rpID, ".") { + return "", ErrPasskeyRPIDInvalid + } + for _, origins := range configuredOrigins { + for origin := range strings.SplitSeq(origins, ",") { + parsed, err := url.Parse(strings.TrimSpace(origin)) + if err != nil || parsed.Scheme != "https" || parsed.User != nil || (parsed.Path != "" && parsed.Path != "/") || parsed.RawQuery != "" || parsed.Fragment != "" { + continue + } + if strings.EqualFold(parsed.Hostname(), rpID) { + return rpID, nil + } + } + } + return "", ErrPasskeyRPIDInvalid +} + +func ParsePasskeyRPIDs(value string, configuredOrigins ...string) ([]string, error) { + ids := []string{} + for _, value := range strings.FieldsFunc(value, func(c rune) bool { return c == ',' || c == '\n' || c == '\r' }) { + if strings.TrimSpace(value) == "" { + continue + } + _, err := NormalizePasskeyRPID(value, configuredOrigins...) + if err != nil { + return nil, err + } + // Legacy credentials hash the exact RP ID supplied at registration. + // Validate the domain without changing that historical value. + id := strings.TrimSpace(value) + if !slices.Contains(ids, id) { + ids = append(ids, id) + } + } + return ids, nil +} + +func (s PasskeySettings) RelyingPartyIDs() []string { + ids := []string{} + if primary := s.EffectiveRPID(); primary != "" { + ids = append(ids, primary) + } + legacy, err := ParsePasskeyRPIDs(s.LegacyRPIDs, s.Origins) + if err != nil { + return ids + } + for _, id := range legacy { + if !slices.Contains(ids, id) { + ids = append(ids, id) + } + } + return ids +} + var defaultPasskeySettings = PasskeySettings{ Enabled: false, RPDisplayName: common.SystemName, @@ -33,18 +148,22 @@ func init() { } func GetPasskeySettings() *PasskeySettings { - if defaultPasskeySettings.RPID == "" && ServerAddress != "" { - // 从ServerAddress提取域名作为RPID - // ServerAddress可能是 "https://newapi.pro" 这种格式 - serverAddr := strings.TrimSpace(ServerAddress) - if parsed, err := url.Parse(serverAddr); err == nil && parsed.Host != "" { - defaultPasskeySettings.RPID = parsed.Host - } else { - defaultPasskeySettings.RPID = serverAddr - } - } - if defaultPasskeySettings.Origins == "" || defaultPasskeySettings.Origins == "[]" { - defaultPasskeySettings.Origins = ServerAddress - } return &defaultPasskeySettings } + +func (s PasskeySettings) WithDefaults(serverAddress string) PasskeySettings { + if s.RPID == "" && serverAddress != "" { + // 从ServerAddress提取域名作为RPID + // ServerAddress可能是 "https://newapi.pro" 这种格式 + serverAddr := strings.TrimSpace(serverAddress) + if parsed, err := url.Parse(serverAddr); err == nil && parsed.Host != "" { + s.RPID = parsed.Host + } else { + s.RPID = serverAddr + } + } + if s.Origins == "" || s.Origins == "[]" { + s.Origins = serverAddress + } + return s +} diff --git a/web/src/features/auth/passkey/__tests__/domain-verification.test.tsx b/web/src/features/auth/passkey/__tests__/domain-verification.test.tsx new file mode 100644 index 000000000..3b87e4a1b --- /dev/null +++ b/web/src/features/auth/passkey/__tests__/domain-verification.test.tsx @@ -0,0 +1,419 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +// @vitest-environment-options {"url":"https://www.example.com"} +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { api } from '@/lib/api' +import type { AuthBundle } from '@/stores/auth-store' + +import { verify, verifyLogin } from '../../secure-verification/api' +import { SecureVerificationDialog } from '../../secure-verification/components/secure-verification-dialog' +import { useSecureVerification } from '../../secure-verification/hooks/use-secure-verification' +import type { SecurityProof } from '../../secure-verification/types' +import { PasskeyDomainSelector } from '../components/passkey-domain-selector' + +const storageKey = 'passkey:last-successful-rp-id' +const operation = { + scope: 'channel.key.read', + context: { channel_id: 7 }, +} as const +const proof: SecurityProof = { + proof_token: 'verified-proof', + scope: operation.scope, + method: 'passkey', + expires_at: 4102444800, +} +const credential = { + id: 'credential', + rawId: new Uint8Array([1, 2, 3]).buffer, + type: 'public-key', + response: { + clientDataJSON: new Uint8Array([1]).buffer, + authenticatorData: new Uint8Array([2]).buffer, + signature: new Uint8Array([3]).buffer, + userHandle: new Uint8Array([4]).buffer, + }, + getClientExtensionResults: () => ({}), +} + +function setupPasskeyNetwork() { + const get = vi.fn().mockResolvedValue(credential) + vi.stubGlobal('PublicKeyCredential', class {}) + vi.stubGlobal('navigator', { credentials: { get } }) + vi.spyOn(api, 'get').mockImplementation(async (url) => ({ + data: { + success: true, + data: + url === '/api/status' + ? { + passkey_rp_ids: ['example.com', 'www.example.com'], + passkey_origins: 'https://example.com,https://www.example.com', + } + : { + scope: operation.scope, + methods: [{ method: 'passkey', available: true }], + oauth_providers: [], + password_encryption_enabled: false, + }, + }, + })) + let flow = 0 + const post = vi + .spyOn(api, 'post') + .mockImplementation(async (url, data?: unknown) => { + const request = data as { rp_id?: string } | undefined + if (url.endsWith('/begin')) { + return { + data: { + success: true, + data: { + flow_token: `flow-${++flow}`, + rp_ids: ['example.com', 'www.example.com'], + options: { + publicKey: { + challenge: 'AQID', + rpId: request?.rp_id ?? 'example.com', + }, + }, + }, + }, + } + } + return { data: { success: true, data: proof } } + }) + return { get, post } +} + +function DomainVerificationHarness(props: { + onResult: (proof: SecurityProof | null) => void +}) { + const verification = useSecureVerification() + return ( + <> + + + + ) +} + +beforeEach(() => localStorage.clear()) +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + localStorage.clear() +}) + +describe('Passkey domain compatibility', () => { + it('binds a login factor to its pending login and the selected domain challenge', async () => { + const { get, post } = setupPasskeyNetwork() + const bundle: AuthBundle = { + access_token: 'login-access', + token_type: 'Bearer', + access_expires_at: 4102444800, + user: { id: 7, username: 'user', role: 1 }, + session: { + sid: 'login-session', + current: true, + login_method: 'passkey', + ip: '', + user_agent: '', + created_at: 1, + last_active_at: 1, + expires_at: 4102444800, + }, + } + post + .mockResolvedValueOnce({ + data: { + success: true, + data: { + flow_token: 'domain-flow', + rp_ids: ['www.example.com'], + options: { + publicKey: { challenge: 'AQID', rpId: 'www.example.com' }, + }, + }, + }, + }) + .mockResolvedValueOnce({ data: { success: true, data: bundle } }) + await expect( + verifyLogin( + { method: 'passkey', rpID: 'www.example.com' }, + { + require_verification: true, + flow_token: 'pending-login', + expires_at: 4102444800, + methods: [{ method: 'passkey', available: true }], + }, + new AbortController().signal + ) + ).resolves.toEqual(bundle) + expect(get.mock.calls[0][0].publicKey.rpId).toBe('www.example.com') + expect(post).toHaveBeenNthCalledWith( + 1, + '/api/user/login/passkey/begin', + { + flow_token: 'pending-login', + rp_id: 'www.example.com', + }, + expect.objectContaining({ skipAuthRefresh: true }) + ) + expect(post).toHaveBeenLastCalledWith( + '/api/user/login/passkey/finish', + expect.objectContaining({ + flow_token: 'pending-login', + passkey_flow_token: 'domain-flow', + }), + expect.anything() + ) + expect(localStorage.getItem(storageKey)).toBe('www.example.com') + }) + + it('uses the last successful domain without storing flows or credentials', async () => { + const { get, post } = setupPasskeyNetwork() + localStorage.setItem(storageKey, 'www.example.com') + await expect( + verify( + { method: 'passkey' }, + operation, + false, + new AbortController().signal + ) + ).resolves.toEqual(proof) + expect(post).toHaveBeenNthCalledWith( + 1, + '/api/user/passkey/verify/begin', + { ...operation, rp_id: 'www.example.com' }, + expect.anything() + ) + expect(get).toHaveBeenCalledOnce() + expect(get.mock.calls[0][0].publicKey.rpId).toBe('www.example.com') + expect(localStorage.getItem(storageKey)).toBe('www.example.com') + expect(localStorage.length).toBe(1) + }) + + it('accepts the credential domain chosen by the server over a remembered hint', async () => { + const { get, post } = setupPasskeyNetwork() + localStorage.setItem(storageKey, 'example.com') + post.mockResolvedValueOnce({ + data: { + success: true, + data: { + flow_token: 'known-flow', + rp_ids: ['www.example.com'], + options: { + publicKey: { challenge: 'AQID', rpId: 'www.example.com' }, + }, + }, + }, + }) + await verify( + { method: 'passkey' }, + operation, + false, + new AbortController().signal + ) + expect(get.mock.calls[0][0].publicKey.rpId).toBe('www.example.com') + expect(localStorage.getItem(storageKey)).toBe('www.example.com') + }) + + it('discards a removed remembered domain before opening a single browser prompt', async () => { + const { get, post } = setupPasskeyNetwork() + localStorage.setItem(storageKey, 'removed.example.com') + post.mockResolvedValueOnce({ + data: { success: false, code: 'PASSKEY_RP_ID_UNAVAILABLE' }, + }) + await verify( + { method: 'passkey' }, + operation, + false, + new AbortController().signal + ) + expect(post).toHaveBeenNthCalledWith( + 2, + '/api/user/passkey/verify/begin', + operation, + expect.anything() + ) + expect(get).toHaveBeenCalledOnce() + expect(localStorage.getItem(storageKey)).toBe('example.com') + }) + + it('does not remember or replay a failed finish request', async () => { + const { get, post } = setupPasskeyNetwork() + localStorage.setItem(storageKey, 'example.com') + post + .mockResolvedValueOnce({ + data: { + success: true, + data: { + flow_token: 'flow', + rp_ids: ['example.com', 'www.example.com'], + options: { + publicKey: { challenge: 'AQID', rpId: 'www.example.com' }, + }, + }, + }, + }) + .mockResolvedValueOnce({ + data: { success: false, code: 'SECURITY_VERIFICATION_FAILED' }, + }) + await expect( + verify( + { method: 'passkey', rpID: 'www.example.com' }, + operation, + false, + new AbortController().signal + ) + ).rejects.toThrow() + expect(get).toHaveBeenCalledOnce() + expect(post).toHaveBeenCalledTimes(2) + expect(localStorage.getItem(storageKey)).toBe('example.com') + }) + + it('allows a manual domain switch after cancellation with a fresh challenge', async () => { + const { get, post } = setupPasskeyNetwork() + get.mockRejectedValueOnce( + new DOMException('No matching credential', 'NotAllowedError') + ) + const onResult = vi.fn() + const user = userEvent.setup() + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + render( + + + + ) + await user.click(screen.getByRole('button', { name: 'Protected action' })) + await user.click(await screen.findByRole('button', { name: 'Verify' })) + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Passkey verification was cancelled or timed out' + ) + expect(get).toHaveBeenCalledOnce() + expect(post).toHaveBeenCalledTimes(1) + expect(onResult).not.toHaveBeenCalled() + await user.click( + screen.getByRole('button', { name: 'Use a Passkey from another website' }) + ) + await user.click( + screen.getByRole('combobox', { name: 'Passkey website domain' }) + ) + await user.click( + await screen.findByRole('option', { name: 'www.example.com' }) + ) + await user.click(screen.getByRole('button', { name: 'Verify' })) + await waitFor(() => expect(onResult).toHaveBeenCalledWith(proof)) + expect(get).toHaveBeenCalledTimes(2) + expect(post).toHaveBeenNthCalledWith( + 2, + '/api/user/passkey/verify/begin', + { ...operation, rp_id: 'www.example.com' }, + expect.anything() + ) + expect(post).toHaveBeenLastCalledWith( + '/api/user/passkey/verify/finish', + expect.objectContaining({ flow_token: 'flow-2' }), + expect.anything() + ) + }) + + it('supports verification when browser storage is blocked', async () => { + const { get } = setupPasskeyNetwork() + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('Blocked') + }) + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('Blocked') + }) + await expect( + verify( + { method: 'passkey' }, + operation, + false, + new AbortController().signal + ) + ).resolves.toEqual(proof) + expect(get).toHaveBeenCalledOnce() + }) + + it('does not submit an assertion after its operation is aborted', async () => { + const { get, post } = setupPasskeyNetwork() + const controller = new AbortController() + get.mockImplementation(async () => { + controller.abort() + return credential + }) + await expect( + verify({ method: 'passkey' }, operation, false, controller.signal) + ).rejects.toThrow() + expect(post).toHaveBeenCalledTimes(1) + expect(localStorage.getItem(storageKey)).toBeNull() + }) +}) + +it('keeps case-sensitive historical domains selectable and labels the distinction', async () => { + setupPasskeyNetwork() + vi.mocked(api.get).mockResolvedValue({ + data: { + success: true, + data: { + passkey_rp_ids: ['www.example.com', 'WWW.example.com'], + passkey_origins: 'https://www.example.com', + }, + }, + }) + const changed = vi.fn() + const user = userEvent.setup() + render( + + + + ) + await user.click( + await screen.findByRole('button', { + name: 'Use a Passkey from another website', + }) + ) + await user.click(screen.getByRole('combobox')) + expect( + await screen.findByRole('option', { name: 'www.example.com' }) + ).toBeVisible() + await user.click( + screen.getByRole('option', { + name: /WWW.example.com.*Historical capitalization/, + }) + ) + expect(changed).toHaveBeenCalledExactlyOnceWith('WWW.example.com') +}) diff --git a/web/src/features/auth/passkey/api.ts b/web/src/features/auth/passkey/api.ts index 3bfd7cfe7..a5cbac871 100644 --- a/web/src/features/auth/passkey/api.ts +++ b/web/src/features/auth/passkey/api.ts @@ -80,36 +80,43 @@ export function deletePasskey( ) } -export async function beginPasskeyLogin(): Promise< - ApiResponse -> { - const res = await api.post>( - '/api/user/passkey/login/begin' +export function beginPasskeyLogin( + rpID?: string, + signal?: AbortSignal +): Promise { + return authResult( + api.post>( + '/api/user/passkey/login/begin', + rpID ? { rp_id: rpID } : undefined, + { ...authRequestOptions, signal, skipAuthRefresh: true } + ) ) - return res.data } export async function finishPasskeyLogin( flowToken: string, - payload: Record + payload: Record, + signal?: AbortSignal ): Promise { const res = await api.post( '/api/user/passkey/login/finish', { flow_token: flowToken, credential: payload }, - { skipAuthRefresh: true } + { skipAuthRefresh: true, signal } ) return res.data } export function beginPasskeyVerification( operation: VerificationOperation, - signal?: AbortSignal + signal?: AbortSignal, + rpID?: string ): Promise { return authResult( api.post( '/api/user/passkey/verify/begin', { scope: operation.scope, + ...(rpID ? { rp_id: rpID } : {}), ...(operation.context ? { context: operation.context } : {}), }, { ...authRequestOptions, signal } diff --git a/web/src/features/auth/passkey/assertion.ts b/web/src/features/auth/passkey/assertion.ts new file mode 100644 index 000000000..eb430149d --- /dev/null +++ b/web/src/features/auth/passkey/assertion.ts @@ -0,0 +1,120 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { + buildAssertionResult, + prepareCredentialRequestOptions, +} from '@/lib/passkey' +import { AuthOperationError } from '@/lib/secure-verification' + +import type { PasskeyOptionsPayload } from './types' + +const rememberedRPIDKey = 'passkey:last-successful-rp-id' + +export interface PasskeyDomains { + rpID?: string + rpIDs: string[] +} + +export interface PasskeySelection { + rpID?: string + onDomains?: (domains: PasskeyDomains) => void +} + +export function rememberPasskeyRPID(rpID?: string) { + if (!rpID) return + try { + localStorage.setItem(rememberedRPIDKey, rpID) + } catch { + // Storage is optional; it never authorizes a credential or a domain. + } +} + +export async function requestPasskeyAssertion( + begin: (rpID?: string) => Promise, + signal?: AbortSignal, + selection: PasskeySelection = {} +): Promise<{ + flowToken: string + assertion: Record + rpID?: string +}> { + signal?.throwIfAborted() + let remembered: string | undefined + try { + const value = localStorage.getItem(rememberedRPIDKey) + if (value && value.length <= 253) remembered = value + } catch { + // Private browsing and blocked storage still support Passkey verification. + } + let payload: PasskeyOptionsPayload + try { + payload = await begin(selection.rpID ?? remembered) + } catch (error) { + const failure = AuthOperationError.from(error) + if ( + selection.rpID || + !remembered || + failure.code !== 'PASSKEY_RP_ID_UNAVAILABLE' + ) { + throw failure + } + signal?.throwIfAborted() + try { + localStorage.removeItem(rememberedRPIDKey) + } catch { + /* optional storage */ + } + // Retry a stale hint before opening a browser prompt. A dismissed prompt + // or a failed finish request is never retried automatically. + payload = await begin() + } + signal?.throwIfAborted() + if (!payload.flow_token) { + throw new AuthOperationError('Verification flow expired') + } + const publicKey = prepareCredentialRequestOptions(payload.options ?? payload) + let rpIDs = publicKey.rpId ? [publicKey.rpId] : [] + if (Array.isArray(payload.rp_ids)) { + rpIDs = payload.rp_ids.filter((id): id is string => typeof id === 'string') + } + selection.onDomains?.({ rpID: publicKey.rpId, rpIDs }) + let credential: PublicKeyCredential | null + try { + credential = (await navigator.credentials.get({ + publicKey, + signal, + })) as PublicKeyCredential | null + } catch (error) { + if (error instanceof DOMException && error.name === 'NotAllowedError') { + throw new AuthOperationError( + 'Passkey verification was cancelled or timed out' + ) + } + throw error + } + signal?.throwIfAborted() + if (!credential) { + throw new AuthOperationError('Passkey verification was cancelled') + } + const assertion = buildAssertionResult(credential) + if (!assertion) { + throw new AuthOperationError('Unable to build Passkey assertion') + } + return { flowToken: payload.flow_token, assertion, rpID: publicKey.rpId } +} diff --git a/web/src/features/auth/passkey/components/passkey-domain-selector.tsx b/web/src/features/auth/passkey/components/passkey-domain-selector.tsx new file mode 100644 index 000000000..5fd4c086b --- /dev/null +++ b/web/src/features/auth/passkey/components/passkey-domain-selector.tsx @@ -0,0 +1,172 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useQuery } from '@tanstack/react-query' +import { useId, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { statusQueryOptions } from '@/lib/status-query' + +import type { PasskeyDomains } from '../assertion' + +interface PasskeyDomainSelectorProps { + domains?: PasskeyDomains | null + value?: string + onChange: (rpID: string) => void + disabled?: boolean +} + +export function PasskeyDomainSelector(props: PasskeyDomainSelectorProps) { + const { t } = useTranslation() + const id = useId() + const [expanded, setExpanded] = useState(false) + const { data: status } = useQuery(statusQueryOptions) + const configured = Array.isArray(status?.passkey_rp_ids) + ? status.passkey_rp_ids.filter( + (value): value is string => typeof value === 'string' + ) + : [] + const candidates = props.domains?.rpIDs ?? configured + const available = candidates.filter((rpID) => { + const domain = rpID.toLowerCase() + return ( + window.location.hostname === domain || + window.location.hostname.endsWith(`.${domain}`) + ) + }) + const otherWebsites: string[] = [] + if (typeof status?.passkey_origins === 'string') { + for (const origin of status.passkey_origins.split(',')) { + try { + const url = new URL(origin.trim()) + if ( + url.protocol !== 'https:' && + !(url.protocol === 'http:' && url.hostname === 'localhost') + ) { + continue + } + if ( + url.username || + url.password || + url.origin === window.location.origin + ) { + continue + } + if ( + !configured.some( + (rpID) => + url.hostname === rpID.toLowerCase() || + url.hostname.endsWith(`.${rpID.toLowerCase()}`) + ) + ) { + continue + } + if (!otherWebsites.includes(url.origin)) otherWebsites.push(url.origin) + } catch { + /* Ignore malformed status entries. */ + } + } + } + if (available.length < 2 && otherWebsites.length === 0) return null + const value = props.value ?? props.domains?.rpID ?? available[0] + return ( +
+ + {expanded && ( +
+ {available.length > 1 && ( + <> + + + + )} +

+ {t( + 'Choose the domain used when this Passkey was created, then try again.' + )} +

+ {otherWebsites.length > 0 && ( +
+

+ {t( + 'If your Passkey belongs to another website, start sign-in there:' + )} +

+ {otherWebsites.map((origin) => ( + + ))} +
+ )} +
+ )} +
+ ) +} diff --git a/web/src/features/auth/passkey/types.ts b/web/src/features/auth/passkey/types.ts index b65bc338b..44f823aa0 100644 --- a/web/src/features/auth/passkey/types.ts +++ b/web/src/features/auth/passkey/types.ts @@ -31,6 +31,7 @@ export interface PasskeyStatus { } export interface PasskeyOptionsPayload { + rp_ids?: string[] options?: unknown flow_token?: string expires_at?: number diff --git a/web/src/features/auth/secure-verification/__tests__/verification-flow.test.tsx b/web/src/features/auth/secure-verification/__tests__/verification-flow.test.tsx index 8ef3fc8b5..7fa55c4a4 100644 --- a/web/src/features/auth/secure-verification/__tests__/verification-flow.test.tsx +++ b/web/src/features/auth/secure-verification/__tests__/verification-flow.test.tsx @@ -16,8 +16,10 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { useState } from 'react' import { afterEach, expect, it, vi } from 'vitest' import { api } from '@/lib/api' @@ -43,8 +45,11 @@ function Harness(props: { operation?: RequestVerificationOptions }) { const verification = useSecureVerification() + const [client] = useState( + () => new QueryClient({ defaultOptions: { queries: { retry: false } } }) + ) return ( - <> + - + ) } @@ -81,8 +86,11 @@ function LoginHarness(props: { onResult: (bundle: AuthBundle | null) => void }) { const verification = useSecureVerification() + const [client] = useState( + () => new QueryClient({ defaultOptions: { queries: { retry: false } } }) + ) return ( - <> + - + ) } it('lets a pending login switch from Passkey to 2FA without using authenticated verification endpoints', async () => { + const get = vi + .spyOn(api, 'get') + .mockResolvedValue({ data: { success: true, data: {} } }) vi.stubGlobal('PublicKeyCredential', class {}) - const get = vi.spyOn(api, 'get') const bundle: AuthBundle = { access_token: 'verified-login', token_type: 'Bearer', @@ -152,7 +162,7 @@ it('lets a pending login switch from Passkey to 2FA without using authenticated signal: expect.any(AbortSignal), }) ) - expect(get).not.toHaveBeenCalled() + expect(get.mock.calls.every(([url]) => url === '/api/status')).toBe(true) }) it.each(['success', 'cancel', 'retry'] as const)( diff --git a/web/src/features/auth/secure-verification/api.ts b/web/src/features/auth/secure-verification/api.ts index a37e9eb56..108c07c4c 100644 --- a/web/src/features/auth/secure-verification/api.ts +++ b/web/src/features/auth/secure-verification/api.ts @@ -18,11 +18,7 @@ For commercial licensing, please contact support@quantumnous.com */ import { api, isAuthBundle } from '@/lib/api' import { buildOAuthAuthorizationUrl } from '@/lib/oauth' -import { - buildAssertionResult, - isPasskeySupported, - prepareCredentialRequestOptions, -} from '@/lib/passkey' +import { isPasskeySupported } from '@/lib/passkey' import { AuthOperationError, authRequestOptions, @@ -37,6 +33,12 @@ import { beginPasskeyVerification, finishPasskeyVerification, } from '../passkey/api' +import { + requestPasskeyAssertion, + rememberPasskeyRPID, + type PasskeyDomains, +} from '../passkey/assertion' +import type { PasskeyOptionsPayload } from '../passkey/types' import type { SystemStatus } from '../types' import type { SecurityProof, @@ -134,7 +136,8 @@ export async function getLoginVerificationRequirements( export async function verifyLogin( input: VerificationInput, challenge: LoginChallenge, - signal: AbortSignal + signal: AbortSignal, + onDomains?: (domains: PasskeyDomains) => void ): Promise { if (challenge.expires_at * 1000 <= Date.now()) { throw new AuthOperationError( @@ -157,28 +160,35 @@ export async function verifyLogin( ) ) } else if (input.method === 'passkey') { - const begin = await authResult<{ flow_token: string; options: unknown }>( - api.post( - '/api/user/login/passkey/begin', - { flow_token: challenge.flow_token }, - options - ) + const passkey = await requestPasskeyAssertion( + (rpID) => + authResult( + api.post( + '/api/user/login/passkey/begin', + { + flow_token: challenge.flow_token, + ...(rpID ? { rp_id: rpID } : {}), + }, + options + ) + ), + signal, + { rpID: input.rpID, onDomains } ) - if (!begin.flow_token) { - throw new AuthOperationError('Verification flow expired') - } - const assertion = await requestPasskeyAssertion(begin.options, signal) result = await authResult( api.post( '/api/user/login/passkey/finish', { flow_token: challenge.flow_token, - passkey_flow_token: begin.flow_token, - credential: assertion, + passkey_flow_token: passkey.flowToken, + credential: passkey.assertion, }, options ) ) + signal.throwIfAborted() + if (!isAuthBundle(result)) throw new AuthOperationError('Login failed') + rememberPasskeyRPID(passkey.rpID) } else { throw new AuthOperationError( 'This verification method is not allowed for this action.' @@ -193,7 +203,8 @@ export async function verify( input: VerificationInput, operation: VerificationOperation, passwordEncryptionEnabled: boolean, - signal: AbortSignal + signal: AbortSignal, + onDomains?: (domains: PasskeyDomains) => void ): Promise { try { const operationFields = { @@ -242,7 +253,7 @@ export async function verify( break } case 'passkey': - proof = await verifyPasskey(operation, signal) + proof = await verifyPasskey(operation, signal, input.rpID, onDomains) break case 'oauth': proof = await verifyOAuth(input.provider, operation, signal) @@ -265,50 +276,30 @@ export async function verify( async function verifyPasskey( operation: VerificationOperation, - signal: AbortSignal + signal: AbortSignal, + rpID?: string, + onDomains?: (domains: PasskeyDomains) => void ): Promise { - const begin = await beginPasskeyVerification(operation, signal) - if (!begin.flow_token) { - throw new AuthOperationError('Verification flow expired') - } - const assertion = await requestPasskeyAssertion( - begin.options ?? begin, + const passkey = await requestPasskeyAssertion( + (selected) => beginPasskeyVerification(operation, signal, selected), + signal, + { rpID, onDomains } + ) + const proof = await finishPasskeyVerification( + passkey.flowToken, + passkey.assertion, signal ) - return finishPasskeyVerification(begin.flow_token, assertion, signal) -} - -async function requestPasskeyAssertion( - options: unknown, - signal: AbortSignal -): Promise> { - const publicKey = prepareCredentialRequestOptions(options) - let credential: PublicKeyCredential | null - try { - credential = (await navigator.credentials.get({ - publicKey, - signal, - })) as PublicKeyCredential | null - } catch (error) { - if (error instanceof DOMException && error.name === 'NotAllowedError') { - throw new AuthOperationError( - 'Passkey verification was cancelled or timed out' - ) - } - throw error - } signal.throwIfAborted() - if (!credential) { - throw new AuthOperationError( - 'Passkey verification was cancelled', - 'AUTH_CANCELLED' - ) + if ( + proof.proof_token && + proof.method === 'passkey' && + proof.scope === operation.scope && + proof.expires_at * 1000 > Date.now() + ) { + rememberPasskeyRPID(passkey.rpID) } - const assertion = buildAssertionResult(credential) - if (!assertion) { - throw new AuthOperationError('Unable to build Passkey assertion') - } - return assertion + return proof } async function verifyOAuth( diff --git a/web/src/features/auth/secure-verification/components/secure-verification-dialog.tsx b/web/src/features/auth/secure-verification/components/secure-verification-dialog.tsx index 8303cd925..05c95250e 100644 --- a/web/src/features/auth/secure-verification/components/secure-verification-dialog.tsx +++ b/web/src/features/auth/secure-verification/components/secure-verification-dialog.tsx @@ -26,6 +26,8 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import type { PasskeyDomains } from '../../passkey/assertion' +import { PasskeyDomainSelector } from '../../passkey/components/passkey-domain-selector' import type { SecureVerificationState, VerificationInput, @@ -34,6 +36,7 @@ import type { interface SecureVerificationDialogProps { state: SecureVerificationState + passkeyDomains?: PasskeyDomains | null onVerify: () => void | Promise onCancel: () => void onRetry: () => void @@ -223,7 +226,15 @@ export function SecureVerificationDialog(props: SecureVerificationDialogProps) { : t('Enter the 6-digit authenticator code.')}

- + + + props.onInputChange({ method: 'passkey', rpID }) + } + disabled={verifying} + />

{t( diff --git a/web/src/features/auth/secure-verification/hooks/use-secure-verification.ts b/web/src/features/auth/secure-verification/hooks/use-secure-verification.ts index 5015e454d..e45b7a454 100644 --- a/web/src/features/auth/secure-verification/hooks/use-secure-verification.ts +++ b/web/src/features/auth/secure-verification/hooks/use-secure-verification.ts @@ -16,11 +16,12 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { useCallback, useEffect, useReducer, useRef } from 'react' +import { useCallback, useEffect, useReducer, useRef, useState } from 'react' import { AuthOperationError } from '@/lib/secure-verification' import type { AuthBundle } from '@/stores/auth-store' +import type { PasskeyDomains } from '../../passkey/assertion' import { checkVerificationMethods, getLoginVerificationRequirements, @@ -140,6 +141,9 @@ type PendingVerification = PendingVerificationBase & export function useSecureVerification() { const [state, dispatch] = useReducer(verificationReducer, { phase: 'idle' }) const pending = useRef(null) + const [passkeyDomains, setPasskeyDomains] = useState( + null + ) const cancel = useCallback(() => { const current = pending.current @@ -147,6 +151,7 @@ export function useSecureVerification() { current?.controller.abort() if (current) current.initialPassword = undefined current?.resolve(null) + setPasskeyDomains(null) dispatch({ type: 'reset' }) }, []) @@ -242,6 +247,7 @@ export function useSecureVerification() { submitting: false, } pending.current = current + setPasskeyDomains(null) void loadRequirements(current) }) }, @@ -264,6 +270,7 @@ export function useSecureVerification() { submitting: false, } pending.current = current + setPasskeyDomains(null) void loadRequirements(current) }) }, @@ -288,7 +295,10 @@ export function useSecureVerification() { const bundle = await verifyLogin( input, current.request.challenge, - current.controller.signal + current.controller.signal, + (domains) => { + if (pending.current === current) setPasskeyDomains(domains) + } ) if (pending.current !== current) return current.resolve(bundle) @@ -297,7 +307,10 @@ export function useSecureVerification() { input, current.request, state.requirements.password_encryption_enabled, - current.controller.signal + current.controller.signal, + (domains) => { + if (pending.current === current) setPasskeyDomains(domains) + } ) if (pending.current !== current) return current.resolve(proof) @@ -334,6 +347,7 @@ export function useSecureVerification() { isActive: state.phase !== 'idle', dialogProps: { state, + passkeyDomains, onCancel: cancel, onRetry: retry, onInputChange: setInput, diff --git a/web/src/features/auth/secure-verification/types.ts b/web/src/features/auth/secure-verification/types.ts index 50a7eece9..fe3616b42 100644 --- a/web/src/features/auth/secure-verification/types.ts +++ b/web/src/features/auth/secure-verification/types.ts @@ -71,7 +71,7 @@ export interface VerificationRequirements { export type VerificationInput = | { method: '2fa'; code: string } | { method: 'password'; password: string } - | { method: 'passkey' } + | { method: 'passkey'; rpID?: string } | { method: 'oauth'; provider: string } | { method: 'session' } diff --git a/web/src/features/auth/sign-in/components/user-auth-form.tsx b/web/src/features/auth/sign-in/components/user-auth-form.tsx index 5f97a43eb..2a98e43f4 100644 --- a/web/src/features/auth/sign-in/components/user-auth-form.tsx +++ b/web/src/features/auth/sign-in/components/user-auth-form.tsx @@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com import { zodResolver } from '@hookform/resolvers/zod' import { Link } from '@tanstack/react-router' import { Loader2, LogIn, KeyRound } from 'lucide-react' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useForm } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -46,14 +46,16 @@ import { loginFormSchema } from '@/features/auth/constants' import { useAuthRedirect } from '@/features/auth/hooks/use-auth-redirect' import { useTurnstile } from '@/features/auth/hooks/use-turnstile' import { beginPasskeyLogin, finishPasskeyLogin } from '@/features/auth/passkey' +import { + requestPasskeyAssertion, + rememberPasskeyRPID, + type PasskeyDomains, +} from '@/features/auth/passkey/assertion' +import { PasskeyDomainSelector } from '@/features/auth/passkey/components/passkey-domain-selector' import type { AuthFormProps } from '@/features/auth/types' import { useStatus } from '@/hooks/use-status' import { handleServerError } from '@/lib/handle-server-error' -import { - buildAssertionResult, - prepareCredentialRequestOptions, - isPasskeySupported as detectPasskeySupport, -} from '@/lib/passkey' +import { isPasskeySupported as detectPasskeySupport } from '@/lib/passkey' import { AuthOperationError } from '@/lib/secure-verification' import { createServerError } from '@/lib/server-error-message' import { cn } from '@/lib/utils' @@ -69,6 +71,12 @@ export function UserAuthForm({ const [agreedToLegal, setAgreedToLegal] = useState(false) const [passkeySupported, setPasskeySupported] = useState(false) const [isPasskeyLoading, setIsPasskeyLoading] = useState(false) + const [passkeyDomains, setPasskeyDomains] = useState( + null + ) + const [passkeyRPID, setPasskeyRPID] = useState() + const passkeyOperation = useRef(null) + useEffect(() => () => passkeyOperation.current?.abort(), []) const [isWeChatDialogOpen, setIsWeChatDialogOpen] = useState(false) const [isWeChatSubmitting, setIsWeChatSubmitting] = useState(false) const [turnstileWidgetKey, setTurnstileWidgetKey] = useState(0) @@ -248,44 +256,32 @@ export function UserAuthForm({ return } + if (passkeyOperation.current) return + const controller = new AbortController() + passkeyOperation.current = controller setIsPasskeyLoading(true) try { - const begin = await beginPasskeyLogin() - if (!begin.success) { - throw createServerError(begin, t('Failed to start Passkey login')) - } - - const publicKey = prepareCredentialRequestOptions( - begin.data?.options ?? begin.data + const passkey = await requestPasskeyAssertion( + (rpID) => beginPasskeyLogin(rpID, controller.signal), + controller.signal, + { rpID: passkeyRPID, onDomains: setPasskeyDomains } ) - const flowToken = begin.data?.flow_token - if (!flowToken) { - throw new Error(t('Login flow expired. Please sign in again.')) - } - - const credential = (await navigator.credentials.get({ - publicKey, - })) as PublicKeyCredential | null - - if (!credential) { - toast.info(t('Passkey login was cancelled')) - return - } - - const assertion = buildAssertionResult(credential) - if (!assertion) { - throw new Error(t('Invalid Passkey response')) - } - - const finish = await finishPasskeyLogin(flowToken, assertion) + const finish = await finishPasskeyLogin( + passkey.flowToken, + passkey.assertion, + controller.signal + ) + controller.signal.throwIfAborted() if (!finish.success) { throw createServerError(finish, t('Failed to complete Passkey login')) } + rememberPasskeyRPID(passkey.rpID) if (await handleLoginResult(finish.data, redirectTo)) { toast.success(t('Signed in with Passkey')) } } catch (error: unknown) { + if (controller.signal.aborted) return if (error instanceof DOMException && error.name === 'NotAllowedError') { toast.info(t('Passkey login was cancelled or timed out')) } else if (error instanceof Error) { @@ -296,6 +292,9 @@ export function UserAuthForm({ ) } } finally { + if (passkeyOperation.current === controller) { + passkeyOperation.current = null + } setIsPasskeyLoading(false) } } @@ -318,6 +317,12 @@ export function UserAuthForm({ )} {t('Sign in with Passkey')} + {!passkeySupported && (

{t('Passkey is not supported on this device.')} diff --git a/web/src/features/system-settings/api.ts b/web/src/features/system-settings/api.ts index 9e9c9a7f2..dc05ed341 100644 --- a/web/src/features/system-settings/api.ts +++ b/web/src/features/system-settings/api.ts @@ -27,6 +27,8 @@ import type { SystemTaskResponse, UpdateOptionRequest, UpdateOptionResponse, + UpdatePasskeyDomainsRequest, + UpdatePasskeyDomainsResponse, UpstreamChannelsResponse, UpstreamRatiosResponse, } from './types' @@ -41,6 +43,20 @@ export async function updateSystemOption(request: UpdateOptionRequest) { return res.data } +export async function updatePasskeyDomains( + request: UpdatePasskeyDomainsRequest +) { + const res = await api.put( + '/api/option/passkey/domains', + request, + { + validateStatus: (status) => + (status >= 200 && status < 300) || status === 409, + } + ) + return res.data +} + export async function confirmPaymentCompliance() { const res = await api.post( '/api/option/payment_compliance', diff --git a/web/src/features/system-settings/auth/__tests__/passkey-settings.test.tsx b/web/src/features/system-settings/auth/__tests__/passkey-settings.test.tsx index c2e16db95..3c86293ca 100644 --- a/web/src/features/system-settings/auth/__tests__/passkey-settings.test.tsx +++ b/web/src/features/system-settings/auth/__tests__/passkey-settings.test.tsx @@ -24,14 +24,19 @@ import userEvent from '@testing-library/user-event' import { createInstance, type i18n } from 'i18next' import { useState } from 'react' import { I18nextProvider } from 'react-i18next' +import { toast } from 'sonner' import { beforeEach, describe, expect, it, vi } from 'vitest' import zh from '@/i18n/locales/zh.json' import { api } from '@/lib/api' import { SettingsPageProvider } from '../../components/settings-page-context' +import type { PasskeyDomainChange } from '../../types' import { PasskeySection } from '../passkey-section' +const domainHint = + 'Enter a domain such as example.com or localhost, without a protocol, port or path. Put addresses with ports in Allowed Passkey websites.' + const defaults = { 'passkey.enabled': true, 'passkey.rp_display_name': 'Example', @@ -42,9 +47,30 @@ const defaults = { 'passkey.attachment_preference': '' as const, } +function domainResponse(overrides: Partial = {}) { + return { + data: { + success: true, + data: { + rp_id: 'example.com', + legacy_rp_ids: window.location.hostname, + origins: defaults['passkey.origins'], + previous_rp_id: window.location.hostname, + effective_rp_id: 'example.com', + removed_rp_ids: [], + affected_credentials: 0, + unknown_credentials: 0, + confirmation_required: false, + removal_confirmation: 'reviewed-domains', + ...overrides, + }, + }, + } +} + let testI18n: i18n -function Fixture(props: { rpId?: string; origins?: string }) { +function Fixture(props: { rpId?: string; origins?: string; legacy?: string }) { const [container, setContainer] = useState(null) const [client] = useState( () => @@ -64,6 +90,7 @@ function Fixture(props: { rpId?: string; origins?: string }) { defaultValues={{ ...defaults, 'passkey.rp_id': props.rpId ?? '', + 'passkey.legacy_rp_ids': props.legacy ?? '', 'passkey.origins': props.origins ?? defaults['passkey.origins'], }} /> @@ -88,30 +115,77 @@ beforeEach(async () => { }) describe('Passkey website guidance', () => { + it('previews server impact and cancels domain removal without saving other settings', async () => { + const put = vi.spyOn(api, 'put').mockResolvedValue({ + data: { + success: true, + data: { + rp_id: 'example.com', + legacy_rp_ids: '', + origins: defaults['passkey.origins'], + previous_rp_id: 'example.com', + effective_rp_id: 'example.com', + removed_rp_ids: ['www.example.com'], + affected_credentials: 2, + unknown_credentials: 7, + confirmation_required: true, + removal_confirmation: 'reviewed-impact', + }, + }, + }) + const user = userEvent.setup() + render() + await user.clear( + screen.getByRole('textbox', { name: 'Compatible Passkey domains' }) + ) + await user.type( + screen.getByRole('textbox', { name: 'Passkey display name' }), + ' changed' + ) + await user.click(screen.getByRole('button', { name: 'Save Changes' })) + const dialog = await screen.findByRole('alertdialog') + expect( + await within(dialog).findByText( + 'Passkeys known to use removed domains: 2' + ) + ).toBeVisible() + expect( + within(dialog).getByText( + 'Passkeys with an unknown domain that may be affected: 7' + ) + ).toBeVisible() + await user.click(within(dialog).getByRole('button', { name: 'Cancel' })) + expect(put).toHaveBeenCalledTimes(1) + expect(put.mock.calls[0]?.[1]).toMatchObject({ preview: true }) + }) it('shows the effective domain for a blank field without suggesting a change and restores focus', async () => { const put = vi.spyOn(api, 'put') const user = userEvent.setup() render() const input = screen.getByRole('textbox', { - name: 'Passkey website domain', + name: 'Primary Passkey domain', }) expect(input).toHaveValue('') await waitFor(() => expect(input).not.toHaveClass('border-amber-500')) expect(input).toHaveAccessibleDescription( - `The system currently uses: ${window.location.hostname}` + `${domainHint} The system currently uses: ${window.location.hostname}` ) expect(screen.queryByRole('dialog')).not.toBeInTheDocument() expect( screen.queryByText(/Passkeys only work on the website/) ).not.toBeInTheDocument() - const help = screen.getByRole('button', { name: 'Why set this?' }) + const domains = screen.getByRole('group', { name: 'Passkey domains' }) + const help = within(within(domains).getByRole('status')).getByRole( + 'button', + { name: 'Why set this?' } + ) help.focus() await user.keyboard('{Enter}') const dialog = screen.getByRole('dialog', { - name: 'Why set a website domain?', + name: 'How to set up Passkey domains', }) expect(dialog).toHaveAccessibleDescription( - 'Passkeys only work on the website they were created for. This helps prevent other websites from misusing them.' + 'New Passkeys use the primary domain. Compatible domains keep existing Passkeys working.' ) expect( await within(dialog).findByText( @@ -135,7 +209,9 @@ describe('Passkey website guidance', () => { }) const put = vi .spyOn(api, 'put') - .mockResolvedValue({ data: { success: true } }) + .mockResolvedValue( + domainResponse({ previous_rp_id: 'example.com', legacy_rp_ids: '' }) + ) const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: 'Why set this?' })) @@ -146,7 +222,7 @@ describe('Passkey website guidance', () => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument() ) const input = screen.getByRole('textbox', { - name: 'Passkey website domain', + name: 'Primary Passkey domain', }) expect(input).toHaveValue('example.com') expect(input).not.toHaveClass('border-amber-500') @@ -156,12 +232,19 @@ describe('Passkey website guidance', () => { expect(put).not.toHaveBeenCalled() await user.click(screen.getByRole('button', { name: 'Save Changes' })) await waitFor(() => - expect(put).toHaveBeenCalledWith('/api/option/', { - key: 'passkey.rp_id', - value: 'example.com', - }) + expect(put).toHaveBeenCalledWith( + '/api/option/passkey/domains', + { + rp_id: 'example.com', + legacy_rp_ids: '', + origins: defaults['passkey.origins'], + preview: false, + removal_confirmation: 'reviewed-domains', + }, + expect.any(Object) + ) ) - expect(put).toHaveBeenCalledTimes(1) + expect(put).toHaveBeenCalledTimes(2) await user.click(screen.getByRole('button', { name: 'Why set this?' })) expect( await screen.findByText('The system currently uses: example.com') @@ -174,7 +257,7 @@ describe('Passkey website guidance', () => { await user.click(screen.getByRole('button', { name: 'Why set this?' })) expect( screen.getByText( - 'If users already have Passkeys, changing this domain may require them to sign in another way and set up their Passkeys again.' + 'When the primary domain changes, the previous domain is automatically kept in the compatible domains list.' ) ).toBeVisible() await user.click( @@ -198,7 +281,7 @@ describe('Passkey website guidance', () => { const user = userEvent.setup() render() const input = screen.getByRole('textbox', { - name: 'Passkey website domain', + name: 'Primary Passkey domain', }) expect(input).not.toHaveClass('border-amber-500') await user.clear(input) @@ -208,7 +291,7 @@ describe('Passkey website guidance', () => { 'focus-visible:border-amber-500' ) expect(input).toHaveAccessibleDescription( - 'This domain does not match the current website. Passkeys may not work here.' + `${domainHint} This domain does not match the current website. Passkeys may not work here.` ) await user.clear(input) await user.type(input, window.location.hostname) @@ -224,7 +307,7 @@ describe('Passkey website guidance', () => { it('keeps a matching parent domain unmarked and explains a missing website beside the website list', async () => { render() expect( - screen.getByRole('textbox', { name: 'Passkey website domain' }) + screen.getByRole('textbox', { name: 'Primary Passkey domain' }) ).not.toHaveClass('border-amber-500') const websites = screen.getByRole('textbox', { name: 'Allowed Passkey websites', @@ -240,24 +323,31 @@ describe('Passkey website guidance', () => { }) it('disables filling while saving and enables it after the save finishes', async () => { - let finishSave!: (value: { data: { success: boolean } }) => void - vi.spyOn(api, 'put').mockImplementation( - () => - new Promise((resolve) => { - finishSave = resolve - }) - ) + const reply = domainResponse({ + rp_id: window.location.hostname, + effective_rp_id: window.location.hostname, + legacy_rp_ids: '', + }) + let finishSave!: (value: ReturnType) => void + vi.spyOn(api, 'put') + .mockResolvedValueOnce(reply) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishSave = resolve + }) + ) const user = userEvent.setup() render() await user.type( - screen.getByRole('textbox', { name: 'Passkey website domain' }), + screen.getByRole('textbox', { name: 'Primary Passkey domain' }), window.location.hostname ) await user.click(screen.getByRole('button', { name: 'Save Changes' })) await user.click(screen.getByRole('button', { name: 'Why set this?' })) const fill = screen.getByRole('button', { name: 'Keep existing domain' }) expect(fill).toBeDisabled() - finishSave({ data: { success: true } }) + finishSave(reply) await waitFor(() => expect(fill).toBeEnabled()) }) @@ -266,15 +356,19 @@ describe('Passkey website guidance', () => { const user = userEvent.setup() render() await waitFor(() => - expect( - screen.getByRole('textbox', { name: '通行密钥使用的网站域名' }) - ).not.toHaveClass('border-amber-500') + expect(screen.getByRole('textbox', { name: '主域名' })).not.toHaveClass( + 'border-amber-500' + ) ) await user.click(screen.getByRole('button', { name: '为什么需要设置?' })) const dialog = screen.getByRole('dialog', { - name: '为什么要填写网站域名?', + name: '如何设置通行密钥域名', }) expect(dialog).not.toHaveTextContent(/RP ID|Origins?|依赖方/) + expect(within(dialog).getByText('主域名')).toBeVisible() + expect(within(dialog).getByText('兼容的通行密钥域名')).toBeVisible() + expect(dialog).toHaveTextContent('每行填写一个已有通行密钥使用的域名') + expect(dialog).toHaveTextContent('无法在 example.com 使用') expect( within(dialog).getByRole('button', { name: '保留现有域名' }) ).toBeVisible() @@ -296,101 +390,186 @@ describe('Passkey website guidance', () => { }) it('requires confirmation before replacing an automatically resolved domain and lets users cancel', async () => { - const put = vi - .spyOn(api, 'put') - .mockResolvedValue({ data: { success: true } }) + const put = vi.spyOn(api, 'put').mockResolvedValue(domainResponse()) const user = userEvent.setup() render() - const input = screen.getByRole('textbox', { - name: 'Passkey website domain', - }) - await waitFor(() => - expect(input).toHaveAccessibleDescription( - `The system currently uses: ${window.location.hostname}` - ) + await user.type( + screen.getByRole('textbox', { name: 'Primary Passkey domain' }), + 'example.com' ) - await user.type(input, 'example.com') await user.click(screen.getByRole('button', { name: 'Save Changes' })) - const dialog = screen.getByRole('alertdialog', { + const dialog = await screen.findByRole('alertdialog', { name: 'Change the Passkey domain?', }) expect(dialog).toHaveTextContent( `Current domain: ${window.location.hostname}` ) expect(dialog).toHaveTextContent('New domain: example.com') - expect(put).not.toHaveBeenCalled() + expect(put).toHaveBeenCalledTimes(1) + expect(put.mock.calls[0]?.[1]).toMatchObject({ preview: true }) await user.click(within(dialog).getByRole('button', { name: 'Cancel' })) - expect(put).not.toHaveBeenCalled() + expect(put).toHaveBeenCalledTimes(1) await user.click(screen.getByRole('button', { name: 'Save Changes' })) - await user.click(screen.getByRole('button', { name: 'Change domain' })) - await waitFor(() => - expect(put).toHaveBeenCalledExactlyOnceWith('/api/option/', { - key: 'passkey.rp_id', - value: 'example.com', - }) + await user.click( + await screen.findByRole('button', { name: 'Change domain' }) ) await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() ) + expect(put).toHaveBeenCalledTimes(3) + expect(put).toHaveBeenLastCalledWith( + '/api/option/passkey/domains', + { + rp_id: 'example.com', + legacy_rp_ids: '', + origins: defaults['passkey.origins'], + preview: false, + removal_confirmation: 'reviewed-domains', + }, + expect.any(Object) + ) + expect( + screen.getByRole('textbox', { name: 'Compatible Passkey domains' }) + ).toHaveValue(window.location.hostname) }) - it('warns before clearing an explicit domain back to automatic configuration', async () => { - const put = vi.spyOn(api, 'put') + it('shows the server resolved default before clearing an explicit domain', async () => { + const put = vi.spyOn(api, 'put').mockResolvedValue( + domainResponse({ + rp_id: '', + previous_rp_id: 'example.com', + effective_rp_id: window.location.hostname, + legacy_rp_ids: 'example.com', + }) + ) const user = userEvent.setup() render() await user.clear( - screen.getByRole('textbox', { name: 'Passkey website domain' }) + screen.getByRole('textbox', { name: 'Primary Passkey domain' }) ) await user.click(screen.getByRole('button', { name: 'Save Changes' })) - const dialog = screen.getByRole('alertdialog', { + const dialog = await screen.findByRole('alertdialog', { name: 'Change the Passkey domain?', }) expect(dialog).toHaveTextContent('Current domain: example.com') - expect(dialog).toHaveTextContent( - 'New domain: Automatic from system website address' - ) - expect(put).not.toHaveBeenCalled() + expect(dialog).toHaveTextContent(`New domain: ${window.location.hostname}`) + expect(put).toHaveBeenCalledTimes(1) + expect(put.mock.calls[0]?.[1]).toMatchObject({ preview: true }) }) - it('still requires confirmation if the effective domain cannot be loaded', async () => { + it('uses the server preview when the public effective domain could not be loaded', async () => { vi.mocked(api.get).mockRejectedValue(new Error('Offline')) - const put = vi.spyOn(api, 'put') + const put = vi.spyOn(api, 'put').mockResolvedValue(domainResponse()) const user = userEvent.setup() render() await user.type( - screen.getByRole('textbox', { name: 'Passkey website domain' }), + screen.getByRole('textbox', { name: 'Primary Passkey domain' }), 'example.com' ) await user.click(screen.getByRole('button', { name: 'Save Changes' })) - expect( - screen.getByRole('alertdialog', { name: 'Change the Passkey domain?' }) - ).toHaveTextContent('Current domain: Unknown') - expect(put).not.toHaveBeenCalled() + expect(await screen.findByRole('alertdialog')).toHaveTextContent( + `Current domain: ${window.location.hostname}` + ) + expect(put).toHaveBeenCalledTimes(1) + expect(put.mock.calls[0]?.[1]).toMatchObject({ preview: true }) }) it('keeps a failed domain change available for retry without an unhandled rejection', async () => { - const put = vi.spyOn(api, 'put').mockRejectedValueOnce(new Error('Offline')) + const errorToast = vi.spyOn(toast, 'error') + const put = vi + .spyOn(api, 'put') + .mockResolvedValueOnce(domainResponse()) + .mockRejectedValueOnce(new Error('Offline')) + .mockResolvedValue(domainResponse()) const user = userEvent.setup() render() const input = screen.getByRole('textbox', { - name: 'Passkey website domain', + name: 'Primary Passkey domain', }) await user.clear(input) await user.type(input, 'example.com') await user.click(screen.getByRole('button', { name: 'Save Changes' })) - await user.click(screen.getByRole('button', { name: 'Change domain' })) - await waitFor(() => expect(put).toHaveBeenCalledTimes(1)) + await user.click( + await screen.findByRole('button', { name: 'Change domain' }) + ) + await waitFor(() => expect(put).toHaveBeenCalledTimes(2)) + await waitFor(() => expect(errorToast).toHaveBeenCalledTimes(1)) expect(screen.getByRole('alertdialog')).toBeInTheDocument() await waitFor(() => expect( screen.getByRole('button', { name: 'Change domain' }) ).toBeEnabled() ) - put.mockResolvedValue({ data: { success: true } }) await user.click(screen.getByRole('button', { name: 'Change domain' })) await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() ) - expect(put).toHaveBeenCalledTimes(2) + expect(put).toHaveBeenCalledTimes(3) + }) + + it('requires a new confirmation when the affected credential count changes', async () => { + const errorToast = vi.spyOn(toast, 'error') + const first = domainResponse({ + previous_rp_id: 'example.com', + legacy_rp_ids: '', + removed_rp_ids: ['www.example.com'], + affected_credentials: 1, + confirmation_required: true, + }) + const updated = domainResponse({ + ...first.data.data, + affected_credentials: 2, + removal_confirmation: 'updated-impact', + }) + const put = vi + .spyOn(api, 'put') + .mockResolvedValueOnce(first) + .mockResolvedValueOnce({ + data: { + ...updated.data, + success: false, + code: 'PASSKEY_RP_ID_REMOVAL_CONFIRMATION_REQUIRED', + message: 'Review the updated impact before confirming again.', + }, + }) + .mockResolvedValueOnce(updated) + const user = userEvent.setup() + render() + await user.clear( + screen.getByRole('textbox', { name: 'Compatible Passkey domains' }) + ) + await user.click(screen.getByRole('button', { name: 'Save Changes' })) + const dialog = await screen.findByRole('alertdialog', { + name: 'Remove compatible Passkey domains?', + }) + expect(dialog).toHaveTextContent('Passkeys known to use removed domains: 1') + await user.click( + within(dialog).getByRole('button', { name: 'Remove domains' }) + ) + expect( + await within(dialog).findByText( + 'Passkeys known to use removed domains: 2' + ) + ).toBeVisible() + expect(within(dialog).getByRole('alert')).toHaveTextContent( + 'Review the updated impact before confirming again.' + ) + await waitFor(() => + expect( + within(dialog).getByRole('button', { name: 'Remove domains' }) + ).toBeEnabled() + ) + await user.click( + within(dialog).getByRole('button', { name: 'Remove domains' }) + ) + await waitFor(() => + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() + ) + expect(put).toHaveBeenCalledTimes(3) + expect(put.mock.calls[2]?.[1]).toMatchObject({ + preview: false, + removal_confirmation: 'updated-impact', + }) + expect(errorToast).not.toHaveBeenCalled() }) }) diff --git a/web/src/features/system-settings/auth/index.tsx b/web/src/features/system-settings/auth/index.tsx index 1144a1d84..eee4f7958 100644 --- a/web/src/features/system-settings/auth/index.tsx +++ b/web/src/features/system-settings/auth/index.tsx @@ -66,6 +66,7 @@ const defaultAuthSettings: AuthSettings = { 'passkey.enabled': false, 'passkey.rp_display_name': '', 'passkey.rp_id': '', + 'passkey.legacy_rp_ids': '', 'passkey.origins': '', 'passkey.allow_insecure_origin': false, 'passkey.user_verification': 'preferred', diff --git a/web/src/features/system-settings/auth/passkey-section.tsx b/web/src/features/system-settings/auth/passkey-section.tsx index 3c14ab9f1..452690037 100644 --- a/web/src/features/system-settings/auth/passkey-section.tsx +++ b/web/src/features/system-settings/auth/passkey-section.tsx @@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com */ import { zodResolver } from '@hookform/resolvers/zod' import { useQuery } from '@tanstack/react-query' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useId, useMemo, useRef, useState } from 'react' import { useForm, useWatch } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -26,6 +26,7 @@ import * as z from 'zod' import { ConfirmDialog } from '@/components/confirm-dialog' import { Dialog } from '@/components/dialog' +import { Alert, AlertDescription } from '@/components/ui/alert' import { Button } from '@/components/ui/button' import { Form, @@ -51,13 +52,24 @@ import { statusQueryOptions } from '@/lib/status-query' import { cn } from '@/lib/utils' import { + SettingsControlGroup, SettingsForm, SettingsSwitchContent, SettingsSwitchItem, } from '../components/settings-form-layout' import { SettingsPageFormActions } from '../components/settings-page-context' import { SettingsSection } from '../components/settings-section' -import { useUpdateOption } from '../hooks/use-update-option' +import { + useUpdateOption, + useUpdatePasskeyDomains, +} from '../hooks/use-update-option' +import type { PasskeyDomainChange } from '../types' + +const passkeyDomainKeys = [ + 'passkey.rp_id', + 'passkey.legacy_rp_ids', + 'passkey.origins', +] as const type AttachmentPreference = '' | 'platform' | 'cross-platform' type AttachmentSelectValue = 'none' | 'platform' | 'cross-platform' @@ -72,6 +84,7 @@ const passkeySchema = z.object({ enabled: z.boolean(), rp_display_name: z.string(), rp_id: z.string(), + legacy_rp_ids: z.string(), origins: z.string(), allow_insecure_origin: z.boolean(), user_verification: z.enum(['required', 'preferred', 'discouraged']), @@ -86,6 +99,7 @@ type FlatPasskeyDefaults = { 'passkey.enabled': boolean 'passkey.rp_display_name': string 'passkey.rp_id': string + 'passkey.legacy_rp_ids'?: string 'passkey.origins': string 'passkey.allow_insecure_origin': boolean 'passkey.user_verification': 'required' | 'preferred' | 'discouraged' @@ -107,6 +121,11 @@ const buildFormDefaults = ( enabled: defaults['passkey.enabled'], rp_display_name: defaults['passkey.rp_display_name'] ?? '', rp_id: defaults['passkey.rp_id'] ?? '', + legacy_rp_ids: (defaults['passkey.legacy_rp_ids'] ?? '') + .split(',') + .map((id) => id.trim()) + .filter(Boolean) + .join('\n'), origins: (defaults['passkey.origins'] ?? '') .split(',') .map((origin) => origin.trim()) @@ -126,6 +145,14 @@ const normalizeFormValues = ( 'passkey.enabled': values.passkey.enabled, 'passkey.rp_display_name': values.passkey.rp_display_name, 'passkey.rp_id': values.passkey.rp_id, + 'passkey.legacy_rp_ids': [ + ...new Set( + values.passkey.legacy_rp_ids + .split(/[,\n]/) + .map((id) => id.trim()) + .filter(Boolean) + ), + ].join(','), 'passkey.origins': values.passkey.origins .split('\n') .map((origin) => origin.trim()) @@ -144,10 +171,16 @@ interface PasskeySectionProps { export function PasskeySection(props: PasskeySectionProps) { const { t } = useTranslation() + const domainGroupTitleId = useId() const updateOption = useUpdateOption() + const updateDomains = useUpdatePasskeyDomains() + const isSaving = updateOption.isPending || updateDomains.isPending const [domainHelpOpen, setDomainHelpOpen] = useState(false) - const [pendingDomainChange, setPendingDomainChange] = - useState(null) + const [pendingDomainChange, setPendingDomainChange] = useState<{ + values: FlatPasskeyDefaults + preview: PasskeyDomainChange + notice?: string + } | null>(null) const formDefaults = useMemo( () => buildFormDefaults(props.defaultValues), @@ -205,54 +238,105 @@ export function PasskeySection(props: PasskeySectionProps) { form.reset(buildFormDefaults(props.defaultValues)) }, [props.defaultValues, form]) - const saveSettings = async (normalized: FlatPasskeyDefaults) => { + const saveSettings = async ( + normalized: FlatPasskeyDefaults, + preview?: PasskeyDomainChange + ) => { const changedKeys = ( Object.keys(normalized) as Array - ).filter((key) => normalized[key] !== baselineRef.current[key]) - + ).filter( + (key) => (normalized[key] ?? '') !== (baselineRef.current[key] ?? '') + ) if (changedKeys.length === 0) { toast.info(t('No changes to save')) return } - + const saved = { ...normalized } try { - for (const key of changedKeys) { - await updateOption.mutateAsync({ - key, - value: normalized[key], + if (passkeyDomainKeys.some((key) => changedKeys.includes(key))) { + const result = await updateDomains.mutateAsync({ + rp_id: normalized['passkey.rp_id'], + legacy_rp_ids: normalized['passkey.legacy_rp_ids'] ?? '', + origins: normalized['passkey.origins'], + preview: false, + removal_confirmation: preview?.removal_confirmation, }) + if (!result.success) { + setPendingDomainChange({ + values: normalized, + preview: result.data, + notice: result.message, + }) + return + } + saved['passkey.rp_id'] = result.data.rp_id + saved['passkey.legacy_rp_ids'] = result.data.legacy_rp_ids + saved['passkey.origins'] = result.data.origins + baselineRef.current = { + ...baselineRef.current, + 'passkey.rp_id': saved['passkey.rp_id'], + 'passkey.legacy_rp_ids': saved['passkey.legacy_rp_ids'], + 'passkey.origins': saved['passkey.origins'], + } + } + for (const key of changedKeys) { + if (passkeyDomainKeys.some((domainKey) => domainKey === key)) continue + await updateOption.mutateAsync({ key, value: normalized[key] ?? '' }) } } catch { - // useUpdateOption reports the error. Keep the form and confirmation for retry. + // The mutation reports the error once. Keep the draft available for retry. return } - - baselineRef.current = normalized - baselineSerializedRef.current = JSON.stringify(normalized) - form.reset(buildFormDefaults(normalized)) + baselineRef.current = saved + baselineSerializedRef.current = JSON.stringify(saved) + form.reset(buildFormDefaults(saved)) setPendingDomainChange(null) } const onSubmit = async (values: PasskeyFormValues) => { const normalized = normalizeFormValues(values) - const nextRPID = normalized['passkey.rp_id'].trim() - const changesDomain = - nextRPID !== baselineRef.current['passkey.rp_id'].trim() && - nextRPID !== existingRPID - if (changesDomain) { - setPendingDomainChange(normalized) + const changesDomains = passkeyDomainKeys.some( + (key) => (normalized[key] ?? '') !== (baselineRef.current[key] ?? '') + ) + if (!changesDomains) { + await saveSettings(normalized) return } - await saveSettings(normalized) + try { + const result = await updateDomains.mutateAsync({ + rp_id: normalized['passkey.rp_id'], + legacy_rp_ids: normalized['passkey.legacy_rp_ids'] ?? '', + origins: normalized['passkey.origins'], + preview: true, + }) + const preview = result.data + const clearsPrimary = + baselineRef.current['passkey.rp_id'].trim() !== '' && + normalized['passkey.rp_id'].trim() === '' + if ( + preview.previous_rp_id !== preview.effective_rp_id || + clearsPrimary || + preview.removed_rp_ids.length > 0 || + preview.confirmation_required + ) { + setPendingDomainChange({ values: normalized, preview }) + return + } + await saveSettings(normalized, preview) + } catch { + // Preview failures are reported by the mutation and never write settings. + } } + const removedDomains = pendingDomainChange?.preview.removed_rp_ids ?? [] + return (

( - + {t('Passkey display name')} - ( - - {t('Passkey website domain')} - - +

+ {t('Passkey domains')} +

+ + + + {previewRPID === '' && + t('Set the website where users will use their Passkeys.')} + {domainMismatch && + t( + 'This domain does not match the current website. Passkeys may not work here.' )} - placeholder={t('e.g. example.com')} - value={field.value ?? ''} - onChange={(event) => field.onChange(event.target.value)} - name={field.name} - onBlur={field.onBlur} - ref={field.ref} - /> -
-
- - {previewRPID === '' && - t('Set the website where users will use their Passkeys.')} - {domainMismatch && - t( - 'This domain does not match the current website. Passkeys may not work here.' - )} - {!hasDomainWarning && - rpId.trim() === '' && - t('The system currently uses: {{domain}}', { - domain: serverRPID, - })} - {!hasDomainWarning && - rpId.trim() !== '' && - t('Enter the website domain, such as example.com.')} - - + + {t('Why set this?')} + + } + footer={ + <> - } - footer={ - <> - - - - } - > + } + setDomainHelpOpen(false) + }} + > + {existingRPID + ? t('Keep existing domain') + : t('Fill in this website')} + + + } + > +
+
+
+ {t('Primary Passkey domain')} +
+
+ {t( + 'New Passkeys use this domain. Enter a domain without https://, a port or a path. An empty field uses the system website address.' + )} +
+
+
+
+ {t('Compatible Passkey domains')} +
+
+ {t( + 'Keep the domains used by existing Passkeys, one per line. If the primary domain changed from www.example.com to example.com, enter www.example.com here.' + )} +
+
+
+

- {t( - 'An empty field uses the system website address. If Passkeys already work, keep the existing domain.' - )} + {t('Current site: {{origin}}', { + origin: currentOrigin, + })}

-
-

- {t('Current site: {{origin}}', { - origin: currentOrigin, + {!existingRPID && ( +

+ {t('For this website, you can enter: {{domain}}', { + domain: currentHostname, })}

- {!existingRPID && ( -

- {t('For this website, you can enter: {{domain}}', { - domain: currentHostname, - })} -

- )} - {serverRPID && !statusError && ( -

- {t('The system currently uses: {{domain}}', { - domain: serverRPID, - })} -

- )} -
- {statusError && ( -

- {t( - 'The current setting could not be loaded. You can still enter the website domain yourself.' - )} + )} + {serverRPID && !statusError && ( +

+ {t('The system currently uses: {{domain}}', { + domain: serverRPID, + })}

)} +
+ {statusError && (

{t( - 'Enter only the domain, without https://, a port or a page path. example.com and www.example.com are different Passkey domains; changing between them does not migrate existing Passkeys.' + 'The current setting could not be loaded. You can still enter the website domain yourself.' )}

-

- {t( - 'If users already have Passkeys, changing this domain may require them to sign in another way and set up their Passkeys again.' - )} -

- {!canUseCurrentSite && ( -

- {t( - 'Use an HTTPS domain (or localhost for development) to configure Passkey.' - )} -

+ )} +

+ {t( + 'A Passkey created for www.example.com cannot be used on example.com. Keep the old website available so users can still verify there.' )} -

+

+

+ {t( + 'When the primary domain changes, the previous domain is automatically kept in the compatible domains list.' + )} +

+ {!canUseCurrentSite && ( +

{t( - 'The button fills in this website and keeps any existing website addresses. Save changes on the settings page to apply.' + 'Use an HTTPS domain (or localhost for development) to configure Passkey.' )}

-
-
- -
- )} - /> + )} +

+ {t( + 'Review the compatible domains and allowed websites, then save changes on the settings page.' + )} +

+ + + + ( + + {t('Primary Passkey domain')} + + field.onChange(event.target.value)} + name={field.name} + onBlur={field.onBlur} + ref={field.ref} + /> + + + {t( + 'Enter a domain such as example.com or localhost, without a protocol, port or path. Put addresses with ports in Allowed Passkey websites.' + )}{' '} + {hasDomainWarning && ( + + {previewRPID === '' && + t( + 'Set the website where users will use their Passkeys.' + )} + {domainMismatch && + t( + 'This domain does not match the current website. Passkeys may not work here.' + )} + + )} + {!hasDomainWarning && rpId.trim() === '' && ( + + {t('The system currently uses: {{domain}}', { + domain: serverRPID, + })} + + )} + + + + )} + /> + + ( + + {t('Compatible Passkey domains')} + +