mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 15:38:22 +08:00
feat: add passkey sign-in settings control
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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 永久保留)
|
||||
|
||||
@@ -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)。',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user