From 97f44b21bb762bb8938afa5410734cc3828fba2b Mon Sep 17 00:00:00 2001 From: shaw Date: Tue, 28 Jul 2026 11:45:18 +0800 Subject: [PATCH] fix: keep passkey switch coupled to WebAuthn config and fix CI issues - parseSettings now reports passkey_enabled=false whenever the WebAuthn deployment config is absent: a stale "true" row left behind after the config is removed previously made the admin update gate reject every settings save while the UI toggle was disabled, leaving no recovery path from the admin panel. Added a regression test. - update the admin settings API contract goldens with the new passkey_enabled/passkey_configured/passkey_rp_id/passkey_rp_origins fields. - errcheck: check rows.Close in passkey repository (repo convention). - staticcheck QF1001: apply De Morgan's law in WebAuthn origin scheme validation. --- backend/internal/config/config.go | 2 +- backend/internal/repository/passkey_repo.go | 2 +- backend/internal/server/api_contract_test.go | 8 ++++++++ backend/internal/service/setting_features.go | 10 ++++++++-- .../service/setting_service_update_test.go | 14 ++++++++++++++ 5 files changed, 32 insertions(+), 4 deletions(-) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 4ac0d57ef7..a97299aea9 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -2634,7 +2634,7 @@ func (c *Config) Validate() error { u.Host = strings.ToLower(u.Host) host := strings.ToLower(u.Hostname()) localDevelopment := host == "localhost" || host == "127.0.0.1" || host == "::1" - if u.Scheme != "https" && !(u.Scheme == "http" && localDevelopment) { + if u.Scheme != "https" && (u.Scheme != "http" || !localDevelopment) { return fmt.Errorf("webauthn.rp_origins entry %q must use HTTPS (HTTP is allowed only for localhost)", origin) } if host != c.WebAuthn.RPID && !strings.HasSuffix(host, "."+c.WebAuthn.RPID) { diff --git a/backend/internal/repository/passkey_repo.go b/backend/internal/repository/passkey_repo.go index f834b255dd..d46124e778 100644 --- a/backend/internal/repository/passkey_repo.go +++ b/backend/internal/repository/passkey_repo.go @@ -92,7 +92,7 @@ func (r *passkeyRepository) ListByUserID( if err != nil { return nil, fmt.Errorf("list passkey credentials: %w", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() records := make([]service.PasskeyCredentialRecord, 0) for rows.Next() { diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 06c738cd3c..bbbc25dfd6 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -711,6 +711,10 @@ func TestAPIContracts(t *testing.T) { "frontend_url": "", "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, @@ -1028,6 +1032,10 @@ func TestAPIContracts(t *testing.T) { "invitation_code_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, diff --git a/backend/internal/service/setting_features.go b/backend/internal/service/setting_features.go index c2beefe76a..50a3fbcfd8 100644 --- a/backend/internal/service/setting_features.go +++ b/backend/internal/service/setting_features.go @@ -182,7 +182,7 @@ func (s *SettingService) PasskeyEnabled(ctx context.Context) (bool, error) { } value, err := s.settingRepo.GetValue(ctx, SettingKeyPasskeyEnabled) if errors.Is(err, ErrSettingNotFound) { - return true, nil // preserve enabled deployments when upgrading from config-only releases + return true, nil // configured deployments default to enabled until the admin persists the switch } if err != nil { return false, fmt.Errorf("read passkey setting: %w", err) @@ -206,10 +206,16 @@ func (s *SettingService) passkeyConfigured() bool { return s != nil && s.cfg != nil && s.cfg.WebAuthn.Enabled } +// passkeySettingEnabled must stay ANDed with passkeyConfigured: a stale +// "true" row after the WebAuthn config is removed would otherwise make the +// admin update gate reject every settings save while the UI toggle is locked. func (s *SettingService) passkeySettingEnabled(settings map[string]string) bool { + if !s.passkeyConfigured() { + return false + } value, ok := settings[SettingKeyPasskeyEnabled] if !ok { - return s.passkeyConfigured() + return true } return value == "true" } diff --git a/backend/internal/service/setting_service_update_test.go b/backend/internal/service/setting_service_update_test.go index e3cbddc09f..b689f56781 100644 --- a/backend/internal/service/setting_service_update_test.go +++ b/backend/internal/service/setting_service_update_test.go @@ -879,3 +879,17 @@ func TestSettingService_PasskeySwitchPersistsAndDefaultsToConfigured(t *testing. require.NoError(t, err) require.False(t, publicSettings.PasskeyEnabled) } + +// 移除 WebAuthn 配置后,残留的 passkey_enabled="true" 不得再让 GetAllSettings +// 报告开关开启:admin 更新门控以此为准,一旦误报为 true 会拒绝所有设置保存, +// 而此时前端开关处于禁用态,管理员无法在 UI 里自救。 +func TestSettingService_StalePasskeyTrueWithoutConfigReportsDisabled(t *testing.T) { + repo := &settingGetAllRepoStub{values: map[string]string{ + SettingKeyPasskeyEnabled: "true", + }} + service := NewSettingService(repo, &config.Config{}) + + settings, err := service.GetAllSettings(context.Background()) + require.NoError(t, err) + require.False(t, settings.PasskeyEnabled) +}