From 357c5b917b18dc6ffcf16eb7e0aa14ee6af26abc Mon Sep 17 00:00:00 2001 From: Zhixuan Jiang Date: Sun, 26 Jul 2026 11:07:12 -0400 Subject: [PATCH] feat: add passkey sign-in settings control --- .../internal/handler/admin/setting_handler.go | 5 ++ .../handler/admin/setting_handler_audit.go | 3 + .../handler/admin/setting_handler_update.go | 18 +++++ backend/internal/handler/dto/settings.go | 10 ++- backend/internal/handler/passkey_handler.go | 28 +++++++ .../internal/handler/passkey_handler_test.go | 74 +++++++++++++++++++ backend/internal/service/domain_constants.go | 3 +- backend/internal/service/setting_features.go | 41 ++++++++++ backend/internal/service/setting_parse.go | 1 + backend/internal/service/setting_public.go | 3 +- .../service/setting_service_update_test.go | 29 ++++++++ backend/internal/service/setting_update.go | 1 + backend/internal/service/settings_view.go | 1 + frontend/src/api/admin/settings.ts | 5 ++ .../user/profile/ProfilePasskeyCard.vue | 13 ++-- .../src/i18n/locales/en/admin/settings.ts | 7 ++ .../src/i18n/locales/zh/admin/settings.ts | 7 ++ frontend/src/views/admin/SettingsView.vue | 61 +++++++++++++++ .../admin/__tests__/SettingsView.spec.ts | 45 +++++++++++ 19 files changed, 343 insertions(+), 12 deletions(-) diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 8c33b783b1..34591c4ee5 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -123,6 +123,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { if paymentCfg == nil { paymentCfg = &service.PaymentConfig{} } + passkeyConfigured, passkeyRPID, passkeyRPOrigins := h.settingService.PasskeyConfiguration() payload := dto.SystemSettings{ RegistrationEnabled: settings.RegistrationEnabled, @@ -134,6 +135,10 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { InvitationCodeEnabled: settings.InvitationCodeEnabled, TotpEnabled: settings.TotpEnabled, TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(), + PasskeyEnabled: settings.PasskeyEnabled, + PasskeyConfigured: passkeyConfigured, + PasskeyRPID: passkeyRPID, + PasskeyRPOrigins: passkeyRPOrigins, SessionBindingEnabled: settings.SessionBindingEnabled, StepUpEnabled: settings.StepUpEnabled, AuditLogRetentionDays: settings.AuditLogRetentionDays, diff --git a/backend/internal/handler/admin/setting_handler_audit.go b/backend/internal/handler/admin/setting_handler_audit.go index c8b8bf1b24..7e59baf3f4 100644 --- a/backend/internal/handler/admin/setting_handler_audit.go +++ b/backend/internal/handler/admin/setting_handler_audit.go @@ -56,6 +56,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.TotpEnabled != after.TotpEnabled { changed = append(changed, "totp_enabled") } + if before.PasskeyEnabled != after.PasskeyEnabled { + changed = append(changed, "passkey_enabled") + } if before.SessionBindingEnabled != after.SessionBindingEnabled { changed = append(changed, "session_binding_enabled") } diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index f852bb7e6f..a517ddfc2b 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -28,6 +28,7 @@ type UpdateSettingsRequest struct { FrontendURL string `json:"frontend_url"` InvitationCodeEnabled bool `json:"invitation_code_enabled"` TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 + PasskeyEnabled *bool `json:"passkey_enabled"` // Passkey 登录(省略=保持现值) SessionBindingEnabled *bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定(省略=保持现值) StepUpEnabled *bool `json:"step_up_enabled"` // 敏感操作 step-up 2FA(省略=保持现值) AuditLogRetentionDays int `json:"audit_log_retention_days"` // 审计日志保留天数 @@ -403,6 +404,17 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { if req.StepUpEnabled != nil { stepUpEnabled = *req.StepUpEnabled } + passkeyEnabled := previousSettings.PasskeyEnabled + if req.PasskeyEnabled != nil { + passkeyEnabled = *req.PasskeyEnabled + } + if passkeyEnabled { + configured, _, _ := h.settingService.PasskeyConfiguration() + if !configured { + response.BadRequest(c, "Passkey sign-in requires a valid WebAuthn RP ID and allowed HTTPS origins in the deployment configuration") + return + } + } forwardedClientIPHeaders := append([]string(nil), previousSettings.ForwardedClientIPHeaders...) if req.ForwardedClientIPHeaders != nil { forwardedClientIPHeaders = append([]string(nil), (*req.ForwardedClientIPHeaders)...) @@ -1253,6 +1265,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { FrontendURL: req.FrontendURL, InvitationCodeEnabled: req.InvitationCodeEnabled, TotpEnabled: req.TotpEnabled, + PasskeyEnabled: passkeyEnabled, SessionBindingEnabled: sessionBindingEnabled, StepUpEnabled: stepUpEnabled, AuditLogRetentionDays: req.AuditLogRetentionDays, @@ -1776,6 +1789,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { if updatedPaymentCfg == nil { updatedPaymentCfg = &service.PaymentConfig{} } + passkeyConfigured, passkeyRPID, passkeyRPOrigins := h.settingService.PasskeyConfiguration() payload := dto.SystemSettings{ RegistrationEnabled: updatedSettings.RegistrationEnabled, @@ -1787,6 +1801,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { InvitationCodeEnabled: updatedSettings.InvitationCodeEnabled, TotpEnabled: updatedSettings.TotpEnabled, TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(), + PasskeyEnabled: updatedSettings.PasskeyEnabled, + PasskeyConfigured: passkeyConfigured, + PasskeyRPID: passkeyRPID, + PasskeyRPOrigins: passkeyRPOrigins, SessionBindingEnabled: updatedSettings.SessionBindingEnabled, StepUpEnabled: updatedSettings.StepUpEnabled, AuditLogRetentionDays: updatedSettings.AuditLogRetentionDays, diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 2931228dc5..3afe48064b 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -36,9 +36,13 @@ type SystemSettings struct { InvitationCodeEnabled bool `json:"invitation_code_enabled"` TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // TOTP 加密密钥是否已配置 - SessionBindingEnabled bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定 - StepUpEnabled bool `json:"step_up_enabled"` // 敏感操作 step-up 2FA - AuditLogRetentionDays int `json:"audit_log_retention_days"` // 审计日志保留天数 + PasskeyEnabled bool `json:"passkey_enabled"` + PasskeyConfigured bool `json:"passkey_configured"` + PasskeyRPID string `json:"passkey_rp_id"` + PasskeyRPOrigins []string `json:"passkey_rp_origins"` + SessionBindingEnabled bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定 + StepUpEnabled bool `json:"step_up_enabled"` // 敏感操作 step-up 2FA + AuditLogRetentionDays int `json:"audit_log_retention_days"` // 审计日志保留天数 LoginAgreementEnabled bool `json:"login_agreement_enabled"` LoginAgreementMode string `json:"login_agreement_mode"` LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` diff --git a/backend/internal/handler/passkey_handler.go b/backend/internal/handler/passkey_handler.go index 9ffc920e0d..c5521ebdd1 100644 --- a/backend/internal/handler/passkey_handler.go +++ b/backend/internal/handler/passkey_handler.go @@ -53,6 +53,9 @@ const passkeyFinishBodyMaxBytes = 64 * 1024 // BeginLogin starts a usernameless, discoverable-credential login ceremony. func (h *PasskeyHandler) BeginLogin(c *gin.Context) { + if !h.requirePasskeysEnabled(c) { + return + } assertion, token, err := h.passkeys.BeginLogin(c.Request.Context()) if err != nil { response.ErrorFrom(c, err) @@ -66,6 +69,9 @@ func (h *PasskeyHandler) BeginLogin(c *gin.Context) { // already supplies phishing-resistant multi-factor authentication and does not // enter the separate TOTP challenge flow. func (h *PasskeyHandler) FinishLogin(c *gin.Context) { + if !h.requirePasskeysEnabled(c) { + return + } req, ok := bindPasskeyFinishRequest(c) if !ok { return @@ -87,6 +93,9 @@ func (h *PasskeyHandler) FinishLogin(c *gin.Context) { } func (h *PasskeyHandler) BeginRegistration(c *gin.Context) { + if !h.requirePasskeysEnabled(c) { + return + } subject, ok := middleware2.GetAuthSubjectFromContext(c) if !ok { response.Unauthorized(c, "User not authenticated") @@ -101,6 +110,9 @@ func (h *PasskeyHandler) BeginRegistration(c *gin.Context) { } func (h *PasskeyHandler) FinishRegistration(c *gin.Context) { + if !h.requirePasskeysEnabled(c) { + return + } subject, ok := middleware2.GetAuthSubjectFromContext(c) if !ok { response.Unauthorized(c, "User not authenticated") @@ -168,6 +180,22 @@ func (h *PasskeyHandler) Delete(c *gin.Context) { response.Success(c, gin.H{"success": true}) } +func (h *PasskeyHandler) requirePasskeysEnabled(c *gin.Context) bool { + if h.settingSvc == nil { + response.ErrorFrom(c, service.ErrPasskeysDisabled) + return false + } + enabled, err := h.settingSvc.PasskeyEnabled(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return false + } + if !enabled { + response.ErrorFrom(c, service.ErrPasskeysDisabled) + } + return enabled +} + func (h *PasskeyHandler) ensureBackendModeAllowsUser(ctx context.Context, user *service.User) error { if err := ensureLoginUserActive(user); err != nil { return err diff --git a/backend/internal/handler/passkey_handler_test.go b/backend/internal/handler/passkey_handler_test.go index 0197ffa05d..0d3b57fae4 100644 --- a/backend/internal/handler/passkey_handler_test.go +++ b/backend/internal/handler/passkey_handler_test.go @@ -1,15 +1,42 @@ package handler import ( + "context" + "errors" "net/http" "net/http/httptest" "strings" "testing" + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) +type passkeySwitchSettingRepo struct { + value string + err error +} + +func (r *passkeySwitchSettingRepo) Get(context.Context, string) (*service.Setting, error) { + return nil, service.ErrSettingNotFound +} +func (r *passkeySwitchSettingRepo) GetValue(context.Context, string) (string, error) { + return r.value, r.err +} +func (r *passkeySwitchSettingRepo) Set(context.Context, string, string) error { return nil } +func (r *passkeySwitchSettingRepo) GetMultiple(context.Context, []string) (map[string]string, error) { + return map[string]string{}, nil +} +func (r *passkeySwitchSettingRepo) SetMultiple(context.Context, map[string]string) error { + return nil +} +func (r *passkeySwitchSettingRepo) GetAll(context.Context) (map[string]string, error) { + return map[string]string{}, nil +} +func (r *passkeySwitchSettingRepo) Delete(context.Context, string) error { return nil } + func TestBindPasskeyFinishRequestRejectsOversizedBody(t *testing.T) { gin.SetMode(gin.TestMode) recorder := httptest.NewRecorder() @@ -25,3 +52,50 @@ func TestBindPasskeyFinishRequestRejectsOversizedBody(t *testing.T) { require.False(t, ok) require.Equal(t, http.StatusBadRequest, recorder.Code) } + +func TestPasskeyBeginLoginRejectsDisabledAdminSwitch(t *testing.T) { + gin.SetMode(gin.TestMode) + repo := &passkeySwitchSettingRepo{value: "false"} + settings := service.NewSettingService(repo, &config.Config{ + WebAuthn: config.WebAuthnConfig{Enabled: true}, + }) + handler := NewPasskeyHandler(nil, nil, settings) + recorder := httptest.NewRecorder() + ginContext, _ := gin.CreateTestContext(recorder) + ginContext.Request = httptest.NewRequest(http.MethodPost, "/api/v1/auth/passkey/login/begin", nil) + + handler.BeginLogin(ginContext) + + require.Equal(t, http.StatusForbidden, recorder.Code) + require.Contains(t, recorder.Body.String(), "PASSKEY_DISABLED") +} + +func TestPasskeyBeginLoginReportsSettingStoreFailure(t *testing.T) { + gin.SetMode(gin.TestMode) + settings := service.NewSettingService( + &passkeySwitchSettingRepo{err: errors.New("database unavailable")}, + &config.Config{WebAuthn: config.WebAuthnConfig{Enabled: true}}, + ) + handler := NewPasskeyHandler(nil, nil, settings) + recorder := httptest.NewRecorder() + ginContext, _ := gin.CreateTestContext(recorder) + ginContext.Request = httptest.NewRequest(http.MethodPost, "/api/v1/auth/passkey/login/begin", nil) + + handler.BeginLogin(ginContext) + + require.Equal(t, http.StatusInternalServerError, recorder.Code) + require.NotContains(t, recorder.Body.String(), "PASSKEY_DISABLED") +} + +func TestPasskeyCredentialListRemainsAvailableWhenSignInDisabled(t *testing.T) { + gin.SetMode(gin.TestMode) + handler := NewPasskeyHandler(nil, nil, nil) + recorder := httptest.NewRecorder() + ginContext, _ := gin.CreateTestContext(recorder) + ginContext.Request = httptest.NewRequest(http.MethodGet, "/api/v1/user/passkeys", nil) + + handler.List(ginContext) + + require.Equal(t, http.StatusUnauthorized, recorder.Code) + require.NotContains(t, recorder.Body.String(), "PASSKEY_DISABLED") +} diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 755790f0ef..a42f535697 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -170,7 +170,8 @@ const ( settingKeyForwardedClientIPModeV2 = "forwarded_client_ip_mode_v2_migrated" // TOTP 双因素认证设置 - SettingKeyTotpEnabled = "totp_enabled" // 是否启用 TOTP 2FA 功能 + SettingKeyTotpEnabled = "totp_enabled" // 是否启用 TOTP 2FA 功能 + SettingKeyPasskeyEnabled = "passkey_enabled" // 是否启用 Passkey 登录(仍要求有效的 WebAuthn 部署配置) // 会话安全设置 SettingKeySessionBindingEnabled = "session_binding_enabled" // 会话 IP/UA 绑定(变更即失效),默认关闭 diff --git a/backend/internal/service/setting_features.go b/backend/internal/service/setting_features.go index 52ba3670ff..c2beefe76a 100644 --- a/backend/internal/service/setting_features.go +++ b/backend/internal/service/setting_features.go @@ -173,6 +173,47 @@ func (s *SettingService) IsTotpEnabled(ctx context.Context) bool { return value == "true" } +// PasskeyEnabled reports the effective runtime switch. WebAuthn deployment +// configuration remains the security boundary; the database setting can only +// disable a valid configured relying party, never replace or weaken it. +func (s *SettingService) PasskeyEnabled(ctx context.Context) (bool, error) { + if !s.passkeyConfigured() { + return false, nil + } + value, err := s.settingRepo.GetValue(ctx, SettingKeyPasskeyEnabled) + if errors.Is(err, ErrSettingNotFound) { + return true, nil // preserve enabled deployments when upgrading from config-only releases + } + if err != nil { + return false, fmt.Errorf("read passkey setting: %w", err) + } + return value == "true", nil +} + +// PasskeyConfiguration returns non-secret relying-party configuration for the +// admin status UI. Enabled configurations have already passed Config.Validate. +func (s *SettingService) PasskeyConfiguration() (configured bool, rpID string, origins []string) { + if s == nil || s.cfg == nil { + return false, "", []string{} + } + origins = append([]string{}, s.cfg.WebAuthn.RPOrigins...) + return s.cfg.WebAuthn.Enabled, + strings.TrimSpace(s.cfg.WebAuthn.RPID), + origins +} + +func (s *SettingService) passkeyConfigured() bool { + return s != nil && s.cfg != nil && s.cfg.WebAuthn.Enabled +} + +func (s *SettingService) passkeySettingEnabled(settings map[string]string) bool { + value, ok := settings[SettingKeyPasskeyEnabled] + if !ok { + return s.passkeyConfigured() + } + return value == "true" +} + // IsTotpEncryptionKeyConfigured 检查 TOTP 加密密钥是否已手动配置 // 只有手动配置了密钥才允许在管理后台启用 TOTP 功能 func (s *SettingService) IsTotpEncryptionKeyConfigured() bool { diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go index adc1195c04..40e528291e 100644 --- a/backend/internal/service/setting_parse.go +++ b/backend/internal/service/setting_parse.go @@ -299,6 +299,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin FrontendURL: settings[SettingKeyFrontendURL], InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", TotpEnabled: settings[SettingKeyTotpEnabled] == "true", + PasskeyEnabled: s.passkeySettingEnabled(settings), SessionBindingEnabled: settings[SettingKeySessionBindingEnabled] == "true", // 默认关闭 StepUpEnabled: settings[SettingKeyStepUpEnabled] == "true", // 默认关闭 AuditLogRetentionDays: parseAuditLogRetentionDays(settings[SettingKeyAuditLogRetentionDays]), diff --git a/backend/internal/service/setting_public.go b/backend/internal/service/setting_public.go index 4ee4964b6e..0a13f8b18c 100644 --- a/backend/internal/service/setting_public.go +++ b/backend/internal/service/setting_public.go @@ -164,6 +164,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyPasswordResetEnabled, SettingKeyInvitationCodeEnabled, SettingKeyTotpEnabled, + SettingKeyPasskeyEnabled, SettingKeyLoginAgreementEnabled, SettingKeyLoginAgreementMode, SettingKeyLoginAgreementUpdatedAt, @@ -289,7 +290,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings PasswordResetEnabled: passwordResetEnabled, InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", TotpEnabled: settings[SettingKeyTotpEnabled] == "true", - PasskeyEnabled: s.cfg != nil && s.cfg.WebAuthn.Enabled, + PasskeyEnabled: s.passkeyConfigured() && s.passkeySettingEnabled(settings), LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true" && len(loginAgreementDocuments) > 0, LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]), LoginAgreementUpdatedAt: loginAgreementUpdatedAt, diff --git a/backend/internal/service/setting_service_update_test.go b/backend/internal/service/setting_service_update_test.go index 3a4050f5b2..e3cbddc09f 100644 --- a/backend/internal/service/setting_service_update_test.go +++ b/backend/internal/service/setting_service_update_test.go @@ -850,3 +850,32 @@ func TestSettingService_UpdateSettings_RejectsInvalidPaymentVisibleMethodSource( require.Equal(t, "INVALID_PAYMENT_VISIBLE_METHOD_SOURCE", infraerrors.Reason(err)) require.Nil(t, repo.updates) } + +func TestSettingService_PasskeySwitchPersistsAndDefaultsToConfigured(t *testing.T) { + cfg := &config.Config{WebAuthn: config.WebAuthnConfig{ + Enabled: true, + RPID: "sub3.nebula-spaces.com", + RPOrigins: []string{"https://sub3.nebula-spaces.com"}, + }} + runtimeRepo := &forwardedIPMigrationRepoStub{values: map[string]string{}} + runtimeService := NewSettingService(runtimeRepo, cfg) + + enabled, err := runtimeService.PasskeyEnabled(context.Background()) + require.NoError(t, err) + require.True(t, enabled) + + updateRepo := &settingUpdateRepoStub{} + updateService := NewSettingService(updateRepo, cfg) + require.NoError(t, updateService.UpdateSettings(context.Background(), &SystemSettings{ + PasskeyEnabled: false, + })) + require.Equal(t, "false", updateRepo.updates[SettingKeyPasskeyEnabled]) + + runtimeRepo.values[SettingKeyPasskeyEnabled] = "false" + enabled, err = runtimeService.PasskeyEnabled(context.Background()) + require.NoError(t, err) + require.False(t, enabled) + publicSettings, err := runtimeService.GetPublicSettings(context.Background()) + require.NoError(t, err) + require.False(t, publicSettings.PasskeyEnabled) +} diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index 7449a3de69..ab26fc494a 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -127,6 +127,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting updates[SettingKeyFrontendURL] = settings.FrontendURL updates[SettingKeyInvitationCodeEnabled] = strconv.FormatBool(settings.InvitationCodeEnabled) updates[SettingKeyTotpEnabled] = strconv.FormatBool(settings.TotpEnabled) + updates[SettingKeyPasskeyEnabled] = strconv.FormatBool(settings.PasskeyEnabled) updates[SettingKeySessionBindingEnabled] = strconv.FormatBool(settings.SessionBindingEnabled) updates[SettingKeyStepUpEnabled] = strconv.FormatBool(settings.StepUpEnabled) updates[SettingKeyAuditLogRetentionDays] = strconv.Itoa(settings.AuditLogRetentionDays) diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index e4d9096607..bc88e49118 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -20,6 +20,7 @@ type SystemSettings struct { FrontendURL string InvitationCodeEnabled bool TotpEnabled bool // TOTP 双因素认证 + PasskeyEnabled bool // Passkey 登录 SessionBindingEnabled bool // 会话 IP/UA 绑定(变更即失效) StepUpEnabled bool // 敏感操作 step-up 2FA 门控 AuditLogRetentionDays int // 审计日志保留天数(<=0 永久保留) diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 8861ce56bd..f48b4b7587 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -366,6 +366,10 @@ export interface SystemSettings { invitation_code_enabled: boolean; totp_enabled: boolean; // TOTP 双因素认证 totp_encryption_key_configured: boolean; // TOTP 加密密钥是否已配置 + passkey_enabled: boolean; + passkey_configured: boolean; + passkey_rp_id: string; + passkey_rp_origins: string[]; session_binding_enabled: boolean; // 会话 IP/UA 绑定 step_up_enabled: boolean; // 敏感操作 step-up 2FA audit_log_retention_days: number; // 审计日志保留天数 @@ -674,6 +678,7 @@ export interface UpdateSettingsRequest { frontend_url?: string; invitation_code_enabled?: boolean; totp_enabled?: boolean; // TOTP 双因素认证 + passkey_enabled?: boolean; session_binding_enabled?: boolean; // 会话 IP/UA 绑定 step_up_enabled?: boolean; // 敏感操作 step-up 2FA audit_log_retention_days?: number; // 审计日志保留天数 diff --git a/frontend/src/components/user/profile/ProfilePasskeyCard.vue b/frontend/src/components/user/profile/ProfilePasskeyCard.vue index cefed6d6e6..8bdcc1c6a9 100644 --- a/frontend/src/components/user/profile/ProfilePasskeyCard.vue +++ b/frontend/src/components/user/profile/ProfilePasskeyCard.vue @@ -21,15 +21,15 @@
-
+
{{ t('profile.passkey.featureDisabled') }}
-
+
{{ t('profile.passkey.unsupported') }}
-
+
@@ -135,7 +135,6 @@ const newName = ref('') const credentials = ref([]) async function loadCredentials(): Promise { - if (!supported) return loading.value = true try { credentials.value = await passkeyAPI.list() @@ -209,8 +208,8 @@ function formatDate(value: string): string { watch( () => props.enabled, - (enabled) => { - if (enabled) void loadCredentials() + () => { + void loadCredentials() }, { immediate: true } ) diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index 8bc1a39cfc..262f45c9dd 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -127,6 +127,13 @@ export default { 'Please configure TOTP_ENCRYPTION_KEY in environment variables first. Generate a key with: openssl rand -hex 32' }, security: { + passkey: 'Passkey Sign-in', + passkeyHint: 'Allow passwordless sign-in and user-managed passkeys when the relying party configuration is valid.', + passkeyConfigured: 'WebAuthn relying party configuration is valid.', + passkeyNotConfigured: 'Configure a valid RP ID and allowed HTTPS origins before enabling passkey sign-in.', + passkeyRPID: 'RP ID', + passkeyOrigins: 'Allowed HTTPS origins', + passkeyValueNotConfigured: 'Not configured', stepUp: 'Step-up 2FA for Sensitive Operations', stepUpHint: 'When enabled, sensitive operations (account/proxy export, backup creation and download, S3 config changes, promoting admins) require a recent TOTP verification (valid for 15 minutes). Your own account must have 2FA enabled before turning this on; turning it off also requires step-up verification.', stepUpEnableRequiresTotp: 'Enable 2FA (TOTP) for your own account in Profile before turning on step-up verification.', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index dccf073c14..d9c5c702de 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -127,6 +127,13 @@ export default { '请先在环境变量中配置 TOTP_ENCRYPTION_KEY。使用命令 openssl rand -hex 32 生成密钥。' }, security: { + passkey: 'Passkey 登录', + passkeyHint: '当依赖方配置有效时,允许无密码登录及用户自行管理 Passkey。', + passkeyConfigured: 'WebAuthn 依赖方配置有效。', + passkeyNotConfigured: '请先配置有效的 RP ID 与允许的 HTTPS 来源,再启用 Passkey 登录。', + passkeyRPID: 'RP ID', + passkeyOrigins: '允许的 HTTPS 来源', + passkeyValueNotConfigured: '未配置', stepUp: '敏感操作二次验证 (step-up 2FA)', stepUpHint: '开启后,账号/代理导出、备份创建与下载、S3 配置修改、提升管理员等敏感操作需要先完成 TOTP 二次验证(15 分钟内有效)。开启前需本人已启用 2FA;关闭该开关本身也需要二次验证。', stepUpEnableRequiresTotp: '开启敏感操作二次验证前,请先在个人资料中为当前账号启用 2FA (TOTP)。', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 120a631740..6e82b60034 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -1578,6 +1578,62 @@ />
+ +
+
+
+ +

+ {{ t("admin.settings.security.passkeyHint") }} +

+
+ +
+
+

+ {{ + form.passkey_configured + ? t("admin.settings.security.passkeyConfigured") + : t("admin.settings.security.passkeyNotConfigured") + }} +

+

+ {{ t("admin.settings.security.passkeyRPID") }}: + {{ + form.passkey_rp_id || + t("admin.settings.security.passkeyValueNotConfigured") + }} +

+

+ {{ t("admin.settings.security.passkeyOrigins") }}: + {{ + form.passkey_rp_origins.length > 0 + ? form.passkey_rp_origins.join(", ") + : t( + "admin.settings.security.passkeyValueNotConfigured", + ) + }} +

+
+
+
({ password_reset_enabled: false, totp_enabled: false, totp_encryption_key_configured: false, + passkey_enabled: false, + passkey_configured: false, + passkey_rp_id: "", + passkey_rp_origins: [], session_binding_enabled: false, step_up_enabled: false, audit_log_retention_days: 180, @@ -10010,6 +10070,7 @@ async function saveSettings() { invitation_code_enabled: form.invitation_code_enabled, password_reset_enabled: form.password_reset_enabled, totp_enabled: form.totp_enabled, + passkey_enabled: form.passkey_enabled, session_binding_enabled: form.session_binding_enabled, step_up_enabled: form.step_up_enabled, // 清空数字框时 v-model.number 会得到空串,后端 int 字段解析空串会 400 拒绝整次保存; diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts index 76ecc6c6b5..e87db335c9 100644 --- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts +++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts @@ -350,6 +350,10 @@ const baseSettingsResponse = { password_reset_enabled: false, totp_enabled: false, totp_encryption_key_configured: false, + passkey_enabled: true, + passkey_configured: true, + passkey_rp_id: "sub3.nebula-spaces.com", + passkey_rp_origins: ["https://sub3.nebula-spaces.com"], default_balance: 0, default_concurrency: 1, default_subscriptions: [], @@ -672,6 +676,47 @@ describe("admin SettingsView payment visible method controls", () => { expect(wrapper.text()).not.toContain("支付来源"); }); + it("shows valid passkey RP configuration and persists the sign-in toggle", async () => { + const wrapper = mountView(); + + await flushPromises(); + await openSecurityTab(wrapper); + + const settings = wrapper.get('[data-testid="passkey-settings"]'); + const toggle = settings.get('[data-testid="passkey-toggle"]'); + expect(toggle.attributes("disabled")).toBeUndefined(); + expect(settings.text()).toContain("sub3.nebula-spaces.com"); + expect(settings.text()).toContain("https://sub3.nebula-spaces.com"); + + await toggle.setValue(false); + await wrapper.find("form").trigger("submit.prevent"); + await flushPromises(); + + expect(updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ passkey_enabled: false }), + ); + }); + + it("disables passkey sign-in when the RP configuration is unavailable", async () => { + getSettings.mockResolvedValueOnce({ + ...baseSettingsResponse, + passkey_enabled: false, + passkey_configured: false, + passkey_rp_id: "", + passkey_rp_origins: [], + }); + const wrapper = mountView(); + + await flushPromises(); + await openSecurityTab(wrapper); + + const settings = wrapper.get('[data-testid="passkey-settings"]'); + expect(settings.get('[data-testid="passkey-toggle"]').attributes("disabled")).toBeDefined(); + expect(settings.get('[data-testid="passkey-config-status"]').text()).toContain( + "admin.settings.security.passkeyNotConfigured", + ); + }); + it("loads, edits, validates, and saves forwarded client-IP headers", async () => { getSettings.mockResolvedValueOnce({ ...baseSettingsResponse,