feat: add passkey sign-in settings control

This commit is contained in:
Zhixuan Jiang
2026-07-26 11:07:12 -04:00
parent 4158e73b3f
commit 357c5b917b
19 changed files with 343 additions and 12 deletions
@@ -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,
@@ -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")
}
@@ -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,
+7 -3
View File
@@ -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"`
@@ -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
@@ -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")
}
+2 -1
View File
@@ -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 绑定(变更即失效),默认关闭
@@ -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 {
@@ -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]),
+2 -1
View File
@@ -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,
@@ -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)
}
@@ -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)
@@ -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 永久保留)