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 永久保留)
+5
View File
@@ -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; // 审计日志保留天数
@@ -21,15 +21,15 @@
</div>
<div class="px-6 py-6">
<div v-if="!enabled" class="text-sm text-gray-500 dark:text-gray-400">
<div v-if="!enabled" class="mb-5 text-sm text-gray-500 dark:text-gray-400">
{{ t('profile.passkey.featureDisabled') }}
</div>
<div v-else-if="!supported" class="text-sm text-amber-600 dark:text-amber-400">
<div v-if="enabled && !supported" class="mb-5 text-sm text-amber-600 dark:text-amber-400">
{{ t('profile.passkey.unsupported') }}
</div>
<div v-else>
<div>
<form
v-if="showAddForm"
v-if="enabled && supported && showAddForm"
class="mb-5 flex flex-col gap-3 rounded-lg border border-gray-200 p-4 dark:border-dark-700 sm:flex-row sm:items-end"
@submit.prevent="addPasskey"
>
@@ -135,7 +135,6 @@ const newName = ref('')
const credentials = ref<PasskeyCredentialSummary[]>([])
async function loadCredentials(): Promise<void> {
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 }
)
@@ -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.',
@@ -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)。',
+61
View File
@@ -1578,6 +1578,62 @@
/>
</div>
<!-- Passkey sign-in -->
<div
class="border-t border-gray-100 pt-4 dark:border-dark-700"
data-testid="passkey-settings"
>
<div class="flex items-start justify-between gap-4">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t("admin.settings.security.passkey")
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.security.passkeyHint") }}
</p>
</div>
<Toggle
v-model="form.passkey_enabled"
data-testid="passkey-toggle"
:disabled="!form.passkey_configured"
/>
</div>
<div
class="mt-3 rounded-lg border px-3 py-2 text-sm"
:class="
form.passkey_configured
? 'border-green-200 bg-green-50 text-green-800 dark:border-green-900 dark:bg-green-950/40 dark:text-green-300'
: 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-300'
"
data-testid="passkey-config-status"
>
<p class="font-medium">
{{
form.passkey_configured
? t("admin.settings.security.passkeyConfigured")
: t("admin.settings.security.passkeyNotConfigured")
}}
</p>
<p class="mt-1 break-all">
{{ t("admin.settings.security.passkeyRPID") }}:
{{
form.passkey_rp_id ||
t("admin.settings.security.passkeyValueNotConfigured")
}}
</p>
<p class="mt-1 break-all">
{{ t("admin.settings.security.passkeyOrigins") }}:
{{
form.passkey_rp_origins.length > 0
? form.passkey_rp_origins.join(", ")
: t(
"admin.settings.security.passkeyValueNotConfigured",
)
}}
</p>
</div>
</div>
<!-- 敏感操作 step-up 2FA -->
<div
class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
@@ -8497,6 +8553,10 @@ const form = reactive<SettingsForm>({
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 拒绝整次保存;
@@ -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,