From 563a72ca734774a7744f39876dbff4d3019c9be4 Mon Sep 17 00:00:00 2001 From: shaw Date: Sun, 9 Aug 2026 15:53:40 +0800 Subject: [PATCH] feat: add default-off switch for email domain registration quota MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #5423 relaxed the email suffix whitelist: once a whitelist is configured, non-whitelisted registrable domains are each allowed to register one account. That behavior activated unconditionally. Add registration_email_domain_quota_enabled (default false) to gate it: - Off (default): restore pre-#5423 strict whitelist semantics — with a non-empty whitelist, non-whitelisted domains are rejected with EMAIL_SUFFIX_NOT_ALLOWED; the register/verify views restore the client-side whitelist pre-check and allowed-domain hint. - On: keep #5423 behavior — one account per non-whitelisted registrable domain (EMAIL_DOMAIN_REGISTRATION_LIMIT). - Empty whitelist keeps allowing all domains in both states. Gating lives in validateRegistrationEmailQuota and (as a race-safety backstop) createUserWithRegistrationEmailGuard; the repository-level domain lock + in-tx recheck is unchanged. The admin update field is *bool (omitted = keep current) so stale full-payload saves cannot silently flip the switch. Email binding and OAuth auto-signup keep their strict policy, and pending-OAuth bind-login for existing accounts is unaffected because the handler resolves existing emails before the quota check. Frontend adds the toggle to admin settings (zh/en copy; whitelist hint restored to strict wording, quota wording moved to the new toggle) and exposes the flag via public settings + SSR injection payload. Tests: #5423 quota tests now enable the switch explicitly; new default-off regression tests cover register/send-code/async/pending OAuth/OIDC create-account plus both register views; API contract JSON and the injection drift guard are updated. --- .../internal/handler/admin/setting_handler.go | 1 + .../handler/admin/setting_handler_audit.go | 3 + .../handler/admin/setting_handler_update.go | 115 ++++--- .../handler/auth_oauth_pending_flow_test.go | 57 ++- backend/internal/handler/dto/settings.go | 146 ++++---- backend/internal/handler/setting_handler.go | 115 ++++--- backend/internal/server/api_contract_test.go | 2 + .../service/auth_oauth_email_flow_test.go | 34 +- backend/internal/service/auth_service.go | 8 + .../service/auth_service_register_test.go | 96 +++++- backend/internal/service/domain_constants.go | 39 ++- .../service/registration_email_policy_test.go | 5 +- backend/internal/service/setting_features.go | 10 + backend/internal/service/setting_parse.go | 2 + backend/internal/service/setting_public.go | 324 +++++++++--------- backend/internal/service/setting_update.go | 1 + backend/internal/service/settings_view.go | 98 +++--- frontend/src/api/admin/settings.ts | 2 + .../src/i18n/locales/en/admin/settings.ts | 7 +- .../src/i18n/locales/zh/admin/settings.ts | 7 +- frontend/src/types/index.ts | 1 + frontend/src/views/admin/SettingsView.vue | 20 ++ .../admin/__tests__/SettingsView.spec.ts | 18 +- frontend/src/views/auth/EmailVerifyView.vue | 49 ++- frontend/src/views/auth/RegisterView.vue | 37 +- .../auth/__tests__/EmailVerifyView.spec.ts | 36 ++ .../views/auth/__tests__/RegisterView.spec.ts | 47 ++- 27 files changed, 836 insertions(+), 444 deletions(-) diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 2c12398baa..a5f3d41d90 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -136,6 +136,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { RegistrationEnabled: settings.RegistrationEnabled, EmailVerifyEnabled: settings.EmailVerifyEnabled, RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist, + RegistrationEmailDomainQuotaEnabled: settings.RegistrationEmailDomainQuotaEnabled, PromoCodeEnabled: settings.PromoCodeEnabled, PasswordResetEnabled: settings.PasswordResetEnabled, FrontendURL: settings.FrontendURL, diff --git a/backend/internal/handler/admin/setting_handler_audit.go b/backend/internal/handler/admin/setting_handler_audit.go index e5370bf299..d6110f43d5 100644 --- a/backend/internal/handler/admin/setting_handler_audit.go +++ b/backend/internal/handler/admin/setting_handler_audit.go @@ -41,6 +41,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if !equalStringSlice(before.RegistrationEmailSuffixWhitelist, after.RegistrationEmailSuffixWhitelist) { changed = append(changed, "registration_email_suffix_whitelist") } + if before.RegistrationEmailDomainQuotaEnabled != after.RegistrationEmailDomainQuotaEnabled { + changed = append(changed, "registration_email_domain_quota_enabled") + } if before.PromoCodeEnabled != after.PromoCodeEnabled { changed = append(changed, "promo_code_enabled") } diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index 37971eb826..5884dc392e 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -23,22 +23,23 @@ import ( // UpdateSettingsRequest 更新设置请求 type UpdateSettingsRequest struct { // 注册设置 - RegistrationEnabled bool `json:"registration_enabled"` - EmailVerifyEnabled bool `json:"email_verify_enabled"` - RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` - PromoCodeEnabled bool `json:"promo_code_enabled"` - PasswordResetEnabled bool `json:"password_reset_enabled"` - 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"` // 审计日志保留天数 - LoginAgreementEnabled bool `json:"login_agreement_enabled"` - LoginAgreementMode string `json:"login_agreement_mode"` - LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` - LoginAgreementDocuments []dto.LoginAgreementDocument `json:"login_agreement_documents"` + RegistrationEnabled bool `json:"registration_enabled"` + EmailVerifyEnabled bool `json:"email_verify_enabled"` + RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` + RegistrationEmailDomainQuotaEnabled *bool `json:"registration_email_domain_quota_enabled"` // 非白名单域名限量注册开关(省略=保持现值) + PromoCodeEnabled bool `json:"promo_code_enabled"` + PasswordResetEnabled bool `json:"password_reset_enabled"` + 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"` // 审计日志保留天数 + LoginAgreementEnabled bool `json:"login_agreement_enabled"` + LoginAgreementMode string `json:"login_agreement_mode"` + LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` + LoginAgreementDocuments []dto.LoginAgreementDocument `json:"login_agreement_documents"` // 邮件服务设置 SMTPHost string `json:"smtp_host"` @@ -512,6 +513,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { if req.PasskeyEnabled != nil { passkeyEnabled = *req.PasskeyEnabled } + registrationEmailDomainQuotaEnabled := previousSettings.RegistrationEmailDomainQuotaEnabled + if req.RegistrationEmailDomainQuotaEnabled != nil { + registrationEmailDomainQuotaEnabled = *req.RegistrationEmailDomainQuotaEnabled + } if passkeyEnabled { configured, _, _ := h.settingService.PasskeyConfiguration() if !configured { @@ -1489,44 +1494,45 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { DefaultPlatformQuotas: req.DefaultPlatformQuotas, AccountSchedulingThresholds: req.AccountSchedulingThresholds, - RegistrationEnabled: req.RegistrationEnabled, - EmailVerifyEnabled: req.EmailVerifyEnabled, - RegistrationEmailSuffixWhitelist: req.RegistrationEmailSuffixWhitelist, - PromoCodeEnabled: req.PromoCodeEnabled, - PasswordResetEnabled: req.PasswordResetEnabled, - FrontendURL: req.FrontendURL, - InvitationCodeEnabled: req.InvitationCodeEnabled, - TotpEnabled: req.TotpEnabled, - PasskeyEnabled: passkeyEnabled, - SessionBindingEnabled: sessionBindingEnabled, - StepUpEnabled: stepUpEnabled, - AuditLogRetentionDays: req.AuditLogRetentionDays, - LoginAgreementEnabled: req.LoginAgreementEnabled, - LoginAgreementMode: loginAgreementMode, - LoginAgreementUpdatedAt: loginAgreementUpdatedAt, - LoginAgreementDocuments: loginAgreementDocuments, - SMTPHost: req.SMTPHost, - SMTPPort: req.SMTPPort, - SMTPUsername: req.SMTPUsername, - SMTPPassword: req.SMTPPassword, - SMTPFrom: req.SMTPFrom, - SMTPFromName: req.SMTPFromName, - SMTPUseTLS: req.SMTPUseTLS, - TurnstileEnabled: req.TurnstileEnabled, - TurnstileSiteKey: req.TurnstileSiteKey, - TurnstileSecretKey: req.TurnstileSecretKey, - TencentCaptchaEnabled: req.TencentCaptchaEnabled, - TencentCaptchaAppID: req.TencentCaptchaAppID, - TencentCaptchaAppSecretKey: req.TencentCaptchaAppSecretKey, - TencentCaptchaCloudSecretID: req.TencentCaptchaCloudSecretID, - TencentCaptchaCloudSecretKey: req.TencentCaptchaCloudSecretKey, - TencentCaptchaRegion: req.TencentCaptchaRegion, - AliyunCaptchaEnabled: req.AliyunCaptchaEnabled, - AliyunCaptchaAccessKeyID: req.AliyunCaptchaAccessKeyID, - AliyunCaptchaAccessKeySecret: req.AliyunCaptchaAccessKeySecret, - AliyunCaptchaSceneID: req.AliyunCaptchaSceneID, - AliyunCaptchaPrefix: req.AliyunCaptchaPrefix, - AliyunCaptchaRegion: req.AliyunCaptchaRegion, + RegistrationEnabled: req.RegistrationEnabled, + EmailVerifyEnabled: req.EmailVerifyEnabled, + RegistrationEmailSuffixWhitelist: req.RegistrationEmailSuffixWhitelist, + RegistrationEmailDomainQuotaEnabled: registrationEmailDomainQuotaEnabled, + PromoCodeEnabled: req.PromoCodeEnabled, + PasswordResetEnabled: req.PasswordResetEnabled, + FrontendURL: req.FrontendURL, + InvitationCodeEnabled: req.InvitationCodeEnabled, + TotpEnabled: req.TotpEnabled, + PasskeyEnabled: passkeyEnabled, + SessionBindingEnabled: sessionBindingEnabled, + StepUpEnabled: stepUpEnabled, + AuditLogRetentionDays: req.AuditLogRetentionDays, + LoginAgreementEnabled: req.LoginAgreementEnabled, + LoginAgreementMode: loginAgreementMode, + LoginAgreementUpdatedAt: loginAgreementUpdatedAt, + LoginAgreementDocuments: loginAgreementDocuments, + SMTPHost: req.SMTPHost, + SMTPPort: req.SMTPPort, + SMTPUsername: req.SMTPUsername, + SMTPPassword: req.SMTPPassword, + SMTPFrom: req.SMTPFrom, + SMTPFromName: req.SMTPFromName, + SMTPUseTLS: req.SMTPUseTLS, + TurnstileEnabled: req.TurnstileEnabled, + TurnstileSiteKey: req.TurnstileSiteKey, + TurnstileSecretKey: req.TurnstileSecretKey, + TencentCaptchaEnabled: req.TencentCaptchaEnabled, + TencentCaptchaAppID: req.TencentCaptchaAppID, + TencentCaptchaAppSecretKey: req.TencentCaptchaAppSecretKey, + TencentCaptchaCloudSecretID: req.TencentCaptchaCloudSecretID, + TencentCaptchaCloudSecretKey: req.TencentCaptchaCloudSecretKey, + TencentCaptchaRegion: req.TencentCaptchaRegion, + AliyunCaptchaEnabled: req.AliyunCaptchaEnabled, + AliyunCaptchaAccessKeyID: req.AliyunCaptchaAccessKeyID, + AliyunCaptchaAccessKeySecret: req.AliyunCaptchaAccessKeySecret, + AliyunCaptchaSceneID: req.AliyunCaptchaSceneID, + AliyunCaptchaPrefix: req.AliyunCaptchaPrefix, + AliyunCaptchaRegion: req.AliyunCaptchaRegion, APIKeyACLTrustForwardedIP: func() bool { if req.APIKeyACLTrustForwardedIP != nil { return *req.APIKeyACLTrustForwardedIP @@ -2102,6 +2108,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { RegistrationEnabled: updatedSettings.RegistrationEnabled, EmailVerifyEnabled: updatedSettings.EmailVerifyEnabled, RegistrationEmailSuffixWhitelist: updatedSettings.RegistrationEmailSuffixWhitelist, + RegistrationEmailDomainQuotaEnabled: updatedSettings.RegistrationEmailDomainQuotaEnabled, PromoCodeEnabled: updatedSettings.PromoCodeEnabled, PasswordResetEnabled: updatedSettings.PasswordResetEnabled, FrontendURL: updatedSettings.FrontendURL, diff --git a/backend/internal/handler/auth_oauth_pending_flow_test.go b/backend/internal/handler/auth_oauth_pending_flow_test.go index 20beb21f69..91a65d5f11 100644 --- a/backend/internal/handler/auth_oauth_pending_flow_test.go +++ b/backend/internal/handler/auth_oauth_pending_flow_test.go @@ -1473,7 +1473,8 @@ func TestCreateOIDCOAuthAccountRejectsSecondEmailOutsideRegistrationSuffixWhitel }, }, settingValues: map[string]string{ - service.SettingKeyRegistrationEmailSuffixWhitelist: `["@qq.com"]`, + service.SettingKeyRegistrationEmailSuffixWhitelist: `["@qq.com"]`, + service.SettingKeyRegistrationEmailDomainQuotaEnabled: "true", }, }) ctx := context.Background() @@ -1520,6 +1521,60 @@ func TestCreateOIDCOAuthAccountRejectsSecondEmailOutsideRegistrationSuffixWhitel require.Zero(t, count) } +// 域名限量注册开关默认关闭:白名单外域名保持 PR5423 之前的严格拒绝语义, +// 即使该域名下还没有任何账户也不放行。 +func TestCreateOIDCOAuthAccountRejectsEmailOutsideWhitelistWhenQuotaDisabled(t *testing.T) { + handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{ + emailVerifyEnabled: true, + emailCache: &oauthPendingFlowEmailCacheStub{ + verificationCodes: map[string]*service.VerificationCodeData{ + "foo@gmail.com": { + Code: "135790", + CreatedAt: time.Now().UTC(), + ExpiresAt: time.Now().UTC().Add(15 * time.Minute), + }, + }, + }, + settingValues: map[string]string{ + service.SettingKeyRegistrationEmailSuffixWhitelist: `["@qq.com"]`, + }, + }) + ctx := context.Background() + + session, err := client.PendingAuthSession.Create(). + SetSessionToken("suffix-strict-session-token"). + SetIntent("login"). + SetProviderType("oidc"). + SetProviderKey("https://issuer.example"). + SetProviderSubject("oidc-suffix-strict-123"). + SetBrowserSessionKey("suffix-strict-browser-session-key"). + SetUpstreamIdentityClaims(map[string]any{ + "username": "oidc_user", + }). + SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)). + Save(ctx) + require.NoError(t, err) + + body := bytes.NewBufferString(`{"email":"foo@gmail.com","verify_code":"135790","password":"secret-123"}`) + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/create-account", body) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)}) + req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("suffix-strict-browser-session-key")}) + ginCtx.Request = req + + handler.CreateOIDCOAuthAccount(ginCtx) + + require.Equal(t, http.StatusBadRequest, recorder.Code) + payload := decodeJSONBody(t, recorder) + require.Equal(t, "EMAIL_SUFFIX_NOT_ALLOWED", payload["reason"]) + + count, err := client.User.Query().Where(dbuser.EmailEQ("foo@gmail.com")).Count(ctx) + require.NoError(t, err) + require.Zero(t, count) +} + func TestSendPendingOAuthVerifyCodeExistingEmailReturnsBindLoginState(t *testing.T) { handler, client := newOAuthPendingFlowTestHandlerWithEmailVerification(t, false, "owner@example.com", "135790") ctx := context.Background() diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 6e67cd2ac5..5ab5eaa68b 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -27,26 +27,27 @@ type CustomEndpoint struct { // SystemSettings represents the admin settings API response payload. type SystemSettings struct { - RegistrationEnabled bool `json:"registration_enabled"` - EmailVerifyEnabled bool `json:"email_verify_enabled"` - RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` - PromoCodeEnabled bool `json:"promo_code_enabled"` - PasswordResetEnabled bool `json:"password_reset_enabled"` - FrontendURL string `json:"frontend_url"` - InvitationCodeEnabled bool `json:"invitation_code_enabled"` - TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 - TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // TOTP 加密密钥是否已配置 - 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"` - LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"` + RegistrationEnabled bool `json:"registration_enabled"` + EmailVerifyEnabled bool `json:"email_verify_enabled"` + RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` + RegistrationEmailDomainQuotaEnabled bool `json:"registration_email_domain_quota_enabled"` + PromoCodeEnabled bool `json:"promo_code_enabled"` + PasswordResetEnabled bool `json:"password_reset_enabled"` + FrontendURL string `json:"frontend_url"` + InvitationCodeEnabled bool `json:"invitation_code_enabled"` + TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 + TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // TOTP 加密密钥是否已配置 + 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"` + LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"` SMTPHost string `json:"smtp_host"` SMTPPort int `json:"smtp_port"` @@ -347,58 +348,59 @@ type DefaultSubscriptionSetting struct { } type PublicSettings struct { - RegistrationEnabled bool `json:"registration_enabled"` - EmailVerifyEnabled bool `json:"email_verify_enabled"` - ForceEmailOnThirdPartySignup bool `json:"force_email_on_third_party_signup"` - RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` - PromoCodeEnabled bool `json:"promo_code_enabled"` - PasswordResetEnabled bool `json:"password_reset_enabled"` - InvitationCodeEnabled bool `json:"invitation_code_enabled"` - TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 - PasskeyEnabled bool `json:"passkey_enabled"` - LoginAgreementEnabled bool `json:"login_agreement_enabled"` - LoginAgreementMode string `json:"login_agreement_mode"` - LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` - LoginAgreementRevision string `json:"login_agreement_revision"` - LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"` - TurnstileEnabled bool `json:"turnstile_enabled"` - TurnstileSiteKey string `json:"turnstile_site_key"` - TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"` - TencentCaptchaAppID string `json:"tencent_captcha_app_id"` - TencentCaptchaRegion string `json:"tencent_captcha_region"` - AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"` - AliyunCaptchaSceneID string `json:"aliyun_captcha_scene_id"` - AliyunCaptchaPrefix string `json:"aliyun_captcha_prefix"` - AliyunCaptchaRegion string `json:"aliyun_captcha_region"` - SiteName string `json:"site_name"` - SiteLogo string `json:"site_logo"` - SiteSubtitle string `json:"site_subtitle"` - APIBaseURL string `json:"api_base_url"` - ContactInfo string `json:"contact_info"` - DocURL string `json:"doc_url"` - HomeContent string `json:"home_content"` - CompactHomeEnabled bool `json:"compact_home_enabled"` - HideCcsImportButton bool `json:"hide_ccs_import_button"` - PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` - PurchaseSubscriptionURL string `json:"purchase_subscription_url"` - TableDefaultPageSize int `json:"table_default_page_size"` - TablePageSizeOptions []int `json:"table_page_size_options"` - CustomMenuItems []CustomMenuItem `json:"custom_menu_items"` - CustomEndpoints []CustomEndpoint `json:"custom_endpoints"` - DingTalkOAuthEnabled bool `json:"dingtalk_oauth_enabled"` - LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` - WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"` - WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"` - WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"` - WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"` - OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"` - OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"` - GitHubOAuthEnabled bool `json:"github_oauth_enabled"` - GoogleOAuthEnabled bool `json:"google_oauth_enabled"` - SoraClientEnabled bool `json:"sora_client_enabled"` - BackendModeEnabled bool `json:"backend_mode_enabled"` - PaymentEnabled bool `json:"payment_enabled"` - Version string `json:"version"` + RegistrationEnabled bool `json:"registration_enabled"` + EmailVerifyEnabled bool `json:"email_verify_enabled"` + ForceEmailOnThirdPartySignup bool `json:"force_email_on_third_party_signup"` + RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` + RegistrationEmailDomainQuotaEnabled bool `json:"registration_email_domain_quota_enabled"` + PromoCodeEnabled bool `json:"promo_code_enabled"` + PasswordResetEnabled bool `json:"password_reset_enabled"` + InvitationCodeEnabled bool `json:"invitation_code_enabled"` + TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 + PasskeyEnabled bool `json:"passkey_enabled"` + LoginAgreementEnabled bool `json:"login_agreement_enabled"` + LoginAgreementMode string `json:"login_agreement_mode"` + LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` + LoginAgreementRevision string `json:"login_agreement_revision"` + LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"` + TurnstileEnabled bool `json:"turnstile_enabled"` + TurnstileSiteKey string `json:"turnstile_site_key"` + TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"` + TencentCaptchaAppID string `json:"tencent_captcha_app_id"` + TencentCaptchaRegion string `json:"tencent_captcha_region"` + AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"` + AliyunCaptchaSceneID string `json:"aliyun_captcha_scene_id"` + AliyunCaptchaPrefix string `json:"aliyun_captcha_prefix"` + AliyunCaptchaRegion string `json:"aliyun_captcha_region"` + SiteName string `json:"site_name"` + SiteLogo string `json:"site_logo"` + SiteSubtitle string `json:"site_subtitle"` + APIBaseURL string `json:"api_base_url"` + ContactInfo string `json:"contact_info"` + DocURL string `json:"doc_url"` + HomeContent string `json:"home_content"` + CompactHomeEnabled bool `json:"compact_home_enabled"` + HideCcsImportButton bool `json:"hide_ccs_import_button"` + PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` + PurchaseSubscriptionURL string `json:"purchase_subscription_url"` + TableDefaultPageSize int `json:"table_default_page_size"` + TablePageSizeOptions []int `json:"table_page_size_options"` + CustomMenuItems []CustomMenuItem `json:"custom_menu_items"` + CustomEndpoints []CustomEndpoint `json:"custom_endpoints"` + DingTalkOAuthEnabled bool `json:"dingtalk_oauth_enabled"` + LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` + WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"` + WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"` + WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"` + WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"` + OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"` + OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"` + GitHubOAuthEnabled bool `json:"github_oauth_enabled"` + GoogleOAuthEnabled bool `json:"google_oauth_enabled"` + SoraClientEnabled bool `json:"sora_client_enabled"` + BackendModeEnabled bool `json:"backend_mode_enabled"` + PaymentEnabled bool `json:"payment_enabled"` + Version string `json:"version"` // 服务器全局时区(IANA 名称与当前 UTC 偏移,如 "Asia/Shanghai" / "+08:00")。 // 高峰时段等按服务器本地时间判定的窗口,前端展示时据此标注,避免用户按浏览器本地时间误读。 ServerTimezone string `json:"server_timezone"` diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index 3886f82c2a..a415d67e34 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -44,63 +44,64 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { } response.Success(c, dto.PublicSettings{ - RegistrationEnabled: settings.RegistrationEnabled, - EmailVerifyEnabled: settings.EmailVerifyEnabled, - ForceEmailOnThirdPartySignup: settings.ForceEmailOnThirdPartySignup, - RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist, - PromoCodeEnabled: settings.PromoCodeEnabled, - PasswordResetEnabled: settings.PasswordResetEnabled, - InvitationCodeEnabled: settings.InvitationCodeEnabled, - TotpEnabled: settings.TotpEnabled, - PasskeyEnabled: settings.PasskeyEnabled, - LoginAgreementEnabled: settings.LoginAgreementEnabled, - LoginAgreementMode: settings.LoginAgreementMode, - LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt, - LoginAgreementRevision: settings.LoginAgreementRevision, - LoginAgreementDocuments: publicLoginAgreementDocumentsToDTO(settings.LoginAgreementDocuments), - TurnstileEnabled: settings.TurnstileEnabled, - TurnstileSiteKey: settings.TurnstileSiteKey, - TencentCaptchaEnabled: settings.TencentCaptchaEnabled, - TencentCaptchaAppID: settings.TencentCaptchaAppID, - TencentCaptchaRegion: settings.TencentCaptchaRegion, - AliyunCaptchaEnabled: settings.AliyunCaptchaEnabled, - AliyunCaptchaSceneID: settings.AliyunCaptchaSceneID, - AliyunCaptchaPrefix: settings.AliyunCaptchaPrefix, - AliyunCaptchaRegion: settings.AliyunCaptchaRegion, - SiteName: settings.SiteName, - SiteLogo: settings.SiteLogo, - SiteSubtitle: settings.SiteSubtitle, - APIBaseURL: settings.APIBaseURL, - ContactInfo: settings.ContactInfo, - DocURL: settings.DocURL, - HomeContent: settings.HomeContent, - CompactHomeEnabled: settings.CompactHomeEnabled, - HideCcsImportButton: settings.HideCcsImportButton, - PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, - PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, - TableDefaultPageSize: settings.TableDefaultPageSize, - TablePageSizeOptions: settings.TablePageSizeOptions, - CustomMenuItems: dto.ParseUserVisibleMenuItems(settings.CustomMenuItems), - CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints), - DingTalkOAuthEnabled: settings.DingTalkOAuthEnabled, - LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, - WeChatOAuthEnabled: settings.WeChatOAuthEnabled, - WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled, - WeChatOAuthMPEnabled: settings.WeChatOAuthMPEnabled, - WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled, - OIDCOAuthEnabled: settings.OIDCOAuthEnabled, - OIDCOAuthProviderName: settings.OIDCOAuthProviderName, - GitHubOAuthEnabled: settings.GitHubOAuthEnabled, - GoogleOAuthEnabled: settings.GoogleOAuthEnabled, - BackendModeEnabled: settings.BackendModeEnabled, - PaymentEnabled: settings.PaymentEnabled, - Version: h.version, - ServerTimezone: timezone.Name(), - ServerUTCOffset: timezone.UTCOffset(), - BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled, - AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled, - BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold, - BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL, + RegistrationEnabled: settings.RegistrationEnabled, + EmailVerifyEnabled: settings.EmailVerifyEnabled, + ForceEmailOnThirdPartySignup: settings.ForceEmailOnThirdPartySignup, + RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist, + RegistrationEmailDomainQuotaEnabled: settings.RegistrationEmailDomainQuotaEnabled, + PromoCodeEnabled: settings.PromoCodeEnabled, + PasswordResetEnabled: settings.PasswordResetEnabled, + InvitationCodeEnabled: settings.InvitationCodeEnabled, + TotpEnabled: settings.TotpEnabled, + PasskeyEnabled: settings.PasskeyEnabled, + LoginAgreementEnabled: settings.LoginAgreementEnabled, + LoginAgreementMode: settings.LoginAgreementMode, + LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt, + LoginAgreementRevision: settings.LoginAgreementRevision, + LoginAgreementDocuments: publicLoginAgreementDocumentsToDTO(settings.LoginAgreementDocuments), + TurnstileEnabled: settings.TurnstileEnabled, + TurnstileSiteKey: settings.TurnstileSiteKey, + TencentCaptchaEnabled: settings.TencentCaptchaEnabled, + TencentCaptchaAppID: settings.TencentCaptchaAppID, + TencentCaptchaRegion: settings.TencentCaptchaRegion, + AliyunCaptchaEnabled: settings.AliyunCaptchaEnabled, + AliyunCaptchaSceneID: settings.AliyunCaptchaSceneID, + AliyunCaptchaPrefix: settings.AliyunCaptchaPrefix, + AliyunCaptchaRegion: settings.AliyunCaptchaRegion, + SiteName: settings.SiteName, + SiteLogo: settings.SiteLogo, + SiteSubtitle: settings.SiteSubtitle, + APIBaseURL: settings.APIBaseURL, + ContactInfo: settings.ContactInfo, + DocURL: settings.DocURL, + HomeContent: settings.HomeContent, + CompactHomeEnabled: settings.CompactHomeEnabled, + HideCcsImportButton: settings.HideCcsImportButton, + PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, + PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, + TableDefaultPageSize: settings.TableDefaultPageSize, + TablePageSizeOptions: settings.TablePageSizeOptions, + CustomMenuItems: dto.ParseUserVisibleMenuItems(settings.CustomMenuItems), + CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints), + DingTalkOAuthEnabled: settings.DingTalkOAuthEnabled, + LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, + WeChatOAuthEnabled: settings.WeChatOAuthEnabled, + WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled, + WeChatOAuthMPEnabled: settings.WeChatOAuthMPEnabled, + WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled, + OIDCOAuthEnabled: settings.OIDCOAuthEnabled, + OIDCOAuthProviderName: settings.OIDCOAuthProviderName, + GitHubOAuthEnabled: settings.GitHubOAuthEnabled, + GoogleOAuthEnabled: settings.GoogleOAuthEnabled, + BackendModeEnabled: settings.BackendModeEnabled, + PaymentEnabled: settings.PaymentEnabled, + Version: h.version, + ServerTimezone: timezone.Name(), + ServerUTCOffset: timezone.UTCOffset(), + BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled, + AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled, + BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold, + BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL, ChannelMonitorEnabled: settings.ChannelMonitorEnabled, ChannelMonitorMode: settings.ChannelMonitorMode, diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 196b081985..9ac40fb556 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -714,6 +714,7 @@ func TestAPIContracts(t *testing.T) { "registration_enabled": true, "email_verify_enabled": false, "registration_email_suffix_whitelist": [], + "registration_email_domain_quota_enabled": false, "promo_code_enabled": true, "password_reset_enabled": false, "frontend_url": "", @@ -1059,6 +1060,7 @@ func TestAPIContracts(t *testing.T) { "registration_enabled": true, "email_verify_enabled": false, "registration_email_suffix_whitelist": [], + "registration_email_domain_quota_enabled": false, "promo_code_enabled": true, "password_reset_enabled": false, "frontend_url": "", diff --git a/backend/internal/service/auth_oauth_email_flow_test.go b/backend/internal/service/auth_oauth_email_flow_test.go index 4bdae10dff..6e57e95016 100644 --- a/backend/internal/service/auth_oauth_email_flow_test.go +++ b/backend/internal/service/auth_oauth_email_flow_test.go @@ -206,8 +206,9 @@ func TestRegisterOAuthEmailAccount_NonWhitelistDomainLimit(t *testing.T) { &redeemCodeRepoStub{}, &refreshTokenCacheStub{}, map[string]string{ - SettingKeyRegistrationEnabled: "true", - SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`, + SettingKeyRegistrationEnabled: "true", + SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`, + SettingKeyRegistrationEmailDomainQuotaEnabled: "true", }, &emailCacheStub{data: &VerificationCodeData{ Code: "246810", @@ -236,8 +237,9 @@ func TestRegisterVerifiedOAuthEmailAccount_NonWhitelistDomainLimit(t *testing.T) nil, &refreshTokenCacheStub{}, map[string]string{ - SettingKeyRegistrationEnabled: "true", - SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`, + SettingKeyRegistrationEnabled: "true", + SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`, + SettingKeyRegistrationEmailDomainQuotaEnabled: "true", }, &emailCacheStub{}, nil, @@ -261,8 +263,9 @@ func TestSendPendingOAuthVerifyCode_NonWhitelistDomainLimit(t *testing.T) { nil, nil, map[string]string{ - SettingKeyRegistrationEnabled: "true", - SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`, + SettingKeyRegistrationEnabled: "true", + SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`, + SettingKeyRegistrationEmailDomainQuotaEnabled: "true", }, &emailCacheStub{}, nil, @@ -272,6 +275,25 @@ func TestSendPendingOAuthVerifyCode_NonWhitelistDomainLimit(t *testing.T) { require.ErrorIs(t, err, ErrEmailDomainRegistrationLimit) } +// 域名限量注册开关默认关闭:白名单外域名在 pending OAuth 发码阶段即被严格拒绝。 +func TestSendPendingOAuthVerifyCode_NonWhitelistDomainRejectedWhenQuotaDisabled(t *testing.T) { + userRepo := &userRepoStub{domainCounts: map[string]int{"custom.example": 0}} + authService := newOAuthEmailFlowAuthService( + userRepo, + nil, + nil, + map[string]string{ + SettingKeyRegistrationEnabled: "true", + SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`, + }, + &emailCacheStub{}, + nil, + ) + + _, err := authService.SendPendingOAuthVerifyCode(context.Background(), "first@custom.example") + require.ErrorIs(t, err, ErrEmailSuffixNotAllowed) +} + func TestSendPendingOAuthVerifyCode_NilServiceReturnsUnavailable(t *testing.T) { var authService *AuthService diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index 72ada60155..2beba0d402 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -1207,6 +1207,7 @@ func (s *AuthService) validateRegistrationEmailPolicy(ctx context.Context, email } // validateRegistrationEmailQuota 保留白名单为空时的全放行行为;配置白名单后, +// 非白名单域名默认直接拒绝(严格白名单模式);仅当域名限量注册开关开启时, // 非白名单域名每个最多允许一个账户。 func (s *AuthService) validateRegistrationEmailQuota(ctx context.Context, email string) error { if s.settingService == nil { @@ -1216,6 +1217,9 @@ func (s *AuthService) validateRegistrationEmailQuota(ctx context.Context, email if !IsRegistrationEmailSuffixLimited(email, whitelist) { return nil } + if !s.settingService.IsRegistrationEmailDomainQuotaEnabled(ctx) { + return buildEmailSuffixNotAllowedError(whitelist) + } domain := RegistrationEmailDomain(email) if domain == "" { @@ -1253,6 +1257,10 @@ func (s *AuthService) createUserWithRegistrationEmailGuard(ctx context.Context, if !IsRegistrationEmailSuffixLimited(user.Email, whitelist) { return s.userRepo.CreateWithEmailAliasGuard(ctx, user) } + // 开关关闭时非白名单域名在校验阶段已被拒绝;此处兜底防御设置竞态变更。 + if s.settingService == nil || !s.settingService.IsRegistrationEmailDomainQuotaEnabled(ctx) { + return buildEmailSuffixNotAllowedError(whitelist) + } if domain == "" { return buildEmailSuffixNotAllowedError(whitelist) } diff --git a/backend/internal/service/auth_service_register_test.go b/backend/internal/service/auth_service_register_test.go index 7325ca06dd..04d7efaeb9 100644 --- a/backend/internal/service/auth_service_register_test.go +++ b/backend/internal/service/auth_service_register_test.go @@ -416,8 +416,9 @@ func TestAuthService_Register_ReservedEmail(t *testing.T) { func TestAuthService_Register_EmailSuffixNotAllowed(t *testing.T) { repo := &userRepoStub{domainCounts: map[string]int{"other.com": 1}} service := newAuthService(repo, map[string]string{ - SettingKeyRegistrationEnabled: "true", - SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com","@company.com"]`, + SettingKeyRegistrationEnabled: "true", + SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com","@company.com"]`, + SettingKeyRegistrationEmailDomainQuotaEnabled: "true", }, nil, nil) _, _, err := service.Register(context.Background(), "user@other.com", "password") @@ -430,8 +431,9 @@ func TestAuthService_Register_EmailSuffixNotAllowed(t *testing.T) { func TestAuthService_Register_NonWhitelistDomainAllowsFirstAccount(t *testing.T) { repo := &userRepoStub{nextID: 9, domainCounts: map[string]int{"custom.example": 0}} svc := newAuthService(repo, map[string]string{ - SettingKeyRegistrationEnabled: "true", - SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`, + SettingKeyRegistrationEnabled: "true", + SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`, + SettingKeyRegistrationEmailDomainQuotaEnabled: "true", }, nil, nil) _, user, err := svc.Register(context.Background(), "first@custom.example", "password") @@ -442,14 +444,81 @@ func TestAuthService_Register_NonWhitelistDomainAllowsFirstAccount(t *testing.T) func TestAuthService_Register_NonWhitelistDomainRejectsSecondAccount(t *testing.T) { repo := &userRepoStub{domainCounts: map[string]int{"custom.example": 1}} svc := newAuthService(repo, map[string]string{ - SettingKeyRegistrationEnabled: "true", - SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`, + SettingKeyRegistrationEnabled: "true", + SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`, + SettingKeyRegistrationEmailDomainQuotaEnabled: "true", }, nil, nil) _, _, err := svc.Register(context.Background(), "second@sub.custom.example", "password") require.ErrorIs(t, err, ErrEmailDomainRegistrationLimit) } +// 域名限量注册开关默认关闭:白名单外域名保持 PR5423 之前的严格拒绝语义, +// 即使该域名下还没有任何账户也不放行。 +func TestAuthService_Register_NonWhitelistDomainRejectedWhenQuotaDisabledByDefault(t *testing.T) { + repo := &userRepoStub{nextID: 9, domainCounts: map[string]int{"custom.example": 0}} + svc := newAuthService(repo, map[string]string{ + SettingKeyRegistrationEnabled: "true", + SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`, + }, nil, nil) + + _, _, err := svc.Register(context.Background(), "first@custom.example", "password") + require.ErrorIs(t, err, ErrEmailSuffixNotAllowed) + appErr := infraerrors.FromError(err) + require.Equal(t, "EMAIL_SUFFIX_NOT_ALLOWED", appErr.Reason) + require.Empty(t, repo.created) + require.Zero(t, repo.domainLimitedCreates) +} + +func TestAuthService_Register_NonWhitelistDomainRejectedWhenQuotaExplicitlyDisabled(t *testing.T) { + repo := &userRepoStub{nextID: 9, domainCounts: map[string]int{"custom.example": 0}} + svc := newAuthService(repo, map[string]string{ + SettingKeyRegistrationEnabled: "true", + SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`, + SettingKeyRegistrationEmailDomainQuotaEnabled: "false", + }, nil, nil) + + _, _, err := svc.Register(context.Background(), "first@custom.example", "password") + require.ErrorIs(t, err, ErrEmailSuffixNotAllowed) + require.Empty(t, repo.created) +} + +// 开关关闭不影响白名单命中域名的正常注册。 +func TestAuthService_Register_WhitelistDomainAllowedWhenQuotaDisabled(t *testing.T) { + repo := &userRepoStub{nextID: 12} + svc := newAuthService(repo, map[string]string{ + SettingKeyRegistrationEnabled: "true", + SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`, + }, nil, nil) + + _, user, err := svc.Register(context.Background(), "user@example.com", "password") + require.NoError(t, err) + require.Equal(t, int64(12), user.ID) + require.Zero(t, repo.domainLimitedCreates) +} + +func TestAuthService_SendVerifyCode_NonWhitelistDomainRejectedWhenQuotaDisabled(t *testing.T) { + repo := &userRepoStub{domainCounts: map[string]int{"custom.example": 0}} + svc := newAuthService(repo, map[string]string{ + SettingKeyRegistrationEnabled: "true", + SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`, + }, nil, nil) + + err := svc.SendVerifyCode(context.Background(), "user@custom.example") + require.ErrorIs(t, err, ErrEmailSuffixNotAllowed) +} + +func TestAuthService_SendVerifyCodeAsync_NonWhitelistDomainRejectedWhenQuotaDisabled(t *testing.T) { + repo := &userRepoStub{domainCounts: map[string]int{"custom.example": 0}} + svc := newAuthService(repo, map[string]string{ + SettingKeyRegistrationEnabled: "true", + SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`, + }, nil, nil) + + _, err := svc.SendVerifyCodeAsync(context.Background(), "user@custom.example") + require.ErrorIs(t, err, ErrEmailSuffixNotAllowed) +} + func TestAuthService_Register_EmptyWhitelistAllowsAllDomains(t *testing.T) { repo := &userRepoStub{nextID: 10} svc := newAuthService(repo, map[string]string{ @@ -477,8 +546,9 @@ func TestAuthService_Register_EmailSuffixAllowed(t *testing.T) { func TestAuthService_SendVerifyCode_EmailSuffixNotAllowed(t *testing.T) { repo := &userRepoStub{domainCounts: map[string]int{"other.com": 1}} service := newAuthService(repo, map[string]string{ - SettingKeyRegistrationEnabled: "true", - SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com","@company.com"]`, + SettingKeyRegistrationEnabled: "true", + SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com","@company.com"]`, + SettingKeyRegistrationEmailDomainQuotaEnabled: "true", }, nil, nil) err := service.SendVerifyCode(context.Background(), "user@other.com") @@ -490,8 +560,9 @@ func TestAuthService_SendVerifyCode_EmailSuffixNotAllowed(t *testing.T) { func TestAuthService_SendVerifyCode_NonWhitelistDomainLimit(t *testing.T) { repo := &userRepoStub{domainCounts: map[string]int{"custom.example": 1}} svc := newAuthService(repo, map[string]string{ - SettingKeyRegistrationEnabled: "true", - SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`, + SettingKeyRegistrationEnabled: "true", + SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`, + SettingKeyRegistrationEmailDomainQuotaEnabled: "true", }, nil, nil) err := svc.SendVerifyCode(context.Background(), "user@custom.example") @@ -501,8 +572,9 @@ func TestAuthService_SendVerifyCode_NonWhitelistDomainLimit(t *testing.T) { func TestAuthService_SendVerifyCodeAsync_NonWhitelistDomainLimit(t *testing.T) { repo := &userRepoStub{domainCounts: map[string]int{"custom.example": 1}} svc := newAuthService(repo, map[string]string{ - SettingKeyRegistrationEnabled: "true", - SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`, + SettingKeyRegistrationEnabled: "true", + SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`, + SettingKeyRegistrationEmailDomainQuotaEnabled: "true", }, nil, nil) _, err := svc.SendVerifyCodeAsync(context.Background(), "user@custom.example") diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 1e38b12216..2d5311bb24 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -142,24 +142,27 @@ const ( SettingKeyRegistrationEnabled = "registration_enabled" // 是否开放注册 SettingKeyEmailVerifyEnabled = "email_verify_enabled" // 是否开启邮件验证 SettingKeyRegistrationEmailSuffixWhitelist = "registration_email_suffix_whitelist" // 注册邮箱后缀白名单(JSON 数组) - SettingKeyPromoCodeEnabled = "promo_code_enabled" // 是否启用优惠码功能 - SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证) - SettingKeyFrontendURL = "frontend_url" // 前端基础URL,用于生成邮件中的重置密码链接 - SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册 - SettingKeyAffiliateEnabled = "affiliate_enabled" // 邀请返利功能总开关 - SettingKeyAffiliateRebateRate = "affiliate_rebate_rate" // 邀请返利比例(百分比,0-100) - SettingKeyAffiliateRebateFreezeHours = "affiliate_rebate_freeze_hours" // 返利冻结期(小时,0=不冻结) - SettingKeyAffiliateRebateDurationDays = "affiliate_rebate_duration_days" // 返利有效期(天,0=永久) - SettingKeyAffiliateRebatePerInviteeCap = "affiliate_rebate_per_invitee_cap" // 单人返利上限(0=无上限) - SettingKeyAffiliateAdminRechargeEnabled = "affiliate_admin_recharge_enabled" // 管理员充值是否产生返利 - SettingKeyRiskControlEnabled = "risk_control_enabled" // 是否启用风控中心入口与审计链路 - SettingKeyContentModerationConfig = "content_moderation_config" // 内容审计配置(JSON) - SettingKeyCyberSessionBlockEnabled = "cyber_session_block_enabled" // cyber 命中后会话级自动屏蔽总开关(默认关) - SettingKeyCyberSessionBlockTTLSeconds = "cyber_session_block_ttl_seconds" // 会话屏蔽 TTL 秒数(默认 3600) - SettingKeyLoginAgreementEnabled = "login_agreement_enabled" // 登录前是否要求同意条款 - SettingKeyLoginAgreementMode = "login_agreement_mode" // 条款确认展示模式:modal / checkbox - SettingKeyLoginAgreementUpdatedAt = "login_agreement_updated_at" // 条款更新日期(展示用) - SettingKeyLoginAgreementDocuments = "login_agreement_documents" // 条款文档列表(JSON,Markdown 内容) + // 白名单非空时,是否放行非白名单域名按主域名限量注册(每域名 1 个账户)。 + // 默认 false:非白名单域名直接拒绝(白名单严格模式)。 + SettingKeyRegistrationEmailDomainQuotaEnabled = "registration_email_domain_quota_enabled" + SettingKeyPromoCodeEnabled = "promo_code_enabled" // 是否启用优惠码功能 + SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证) + SettingKeyFrontendURL = "frontend_url" // 前端基础URL,用于生成邮件中的重置密码链接 + SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册 + SettingKeyAffiliateEnabled = "affiliate_enabled" // 邀请返利功能总开关 + SettingKeyAffiliateRebateRate = "affiliate_rebate_rate" // 邀请返利比例(百分比,0-100) + SettingKeyAffiliateRebateFreezeHours = "affiliate_rebate_freeze_hours" // 返利冻结期(小时,0=不冻结) + SettingKeyAffiliateRebateDurationDays = "affiliate_rebate_duration_days" // 返利有效期(天,0=永久) + SettingKeyAffiliateRebatePerInviteeCap = "affiliate_rebate_per_invitee_cap" // 单人返利上限(0=无上限) + SettingKeyAffiliateAdminRechargeEnabled = "affiliate_admin_recharge_enabled" // 管理员充值是否产生返利 + SettingKeyRiskControlEnabled = "risk_control_enabled" // 是否启用风控中心入口与审计链路 + SettingKeyContentModerationConfig = "content_moderation_config" // 内容审计配置(JSON) + SettingKeyCyberSessionBlockEnabled = "cyber_session_block_enabled" // cyber 命中后会话级自动屏蔽总开关(默认关) + SettingKeyCyberSessionBlockTTLSeconds = "cyber_session_block_ttl_seconds" // 会话屏蔽 TTL 秒数(默认 3600) + SettingKeyLoginAgreementEnabled = "login_agreement_enabled" // 登录前是否要求同意条款 + SettingKeyLoginAgreementMode = "login_agreement_mode" // 条款确认展示模式:modal / checkbox + SettingKeyLoginAgreementUpdatedAt = "login_agreement_updated_at" // 条款更新日期(展示用) + SettingKeyLoginAgreementDocuments = "login_agreement_documents" // 条款文档列表(JSON,Markdown 内容) // 邮件服务设置 SettingKeySMTPHost = "smtp_host" // SMTP服务器地址 diff --git a/backend/internal/service/registration_email_policy_test.go b/backend/internal/service/registration_email_policy_test.go index b3e3ad6008..a3067b8379 100644 --- a/backend/internal/service/registration_email_policy_test.go +++ b/backend/internal/service/registration_email_policy_test.go @@ -48,8 +48,9 @@ func TestIsRegistrationEmailSuffixAllowed(t *testing.T) { func TestRegistrationEmailQuotaRejectsMalformedDomainWhenWhitelistConfigured(t *testing.T) { repo := &userRepoStub{} svc := newAuthService(repo, map[string]string{ - SettingKeyRegistrationEnabled: "true", - SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`, + SettingKeyRegistrationEnabled: "true", + SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`, + SettingKeyRegistrationEmailDomainQuotaEnabled: "true", }, nil, nil) _, _, err := svc.Register(context.Background(), "malformed-email", "password") diff --git a/backend/internal/service/setting_features.go b/backend/internal/service/setting_features.go index cfb83f0b5f..7cd647e9fe 100644 --- a/backend/internal/service/setting_features.go +++ b/backend/internal/service/setting_features.go @@ -33,6 +33,16 @@ func (s *SettingService) IsEmailVerifyEnabled(ctx context.Context) bool { return value == "true" } +// IsRegistrationEmailDomainQuotaEnabled 检查白名单非空时是否放行非白名单域名限量注册。 +// 安全默认:设置缺失或查询出错时按关闭处理(保持白名单严格模式)。 +func (s *SettingService) IsRegistrationEmailDomainQuotaEnabled(ctx context.Context) bool { + value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEmailDomainQuotaEnabled) + if err != nil { + return false + } + return value == "true" +} + // GetRegistrationEmailSuffixWhitelist returns normalized registration email suffix whitelist. func (s *SettingService) GetRegistrationEmailSuffixWhitelist(ctx context.Context) []string { value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEmailSuffixWhitelist) diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go index 7701e50e83..283c76f8f4 100644 --- a/backend/internal/service/setting_parse.go +++ b/backend/internal/service/setting_parse.go @@ -58,6 +58,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyRegistrationEnabled: "true", SettingKeyEmailVerifyEnabled: "false", SettingKeyRegistrationEmailSuffixWhitelist: "[]", + SettingKeyRegistrationEmailDomainQuotaEnabled: "false", SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能 SettingKeyLoginAgreementEnabled: "false", SettingKeyLoginAgreementMode: defaultLoginAgreementMode, @@ -310,6 +311,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", EmailVerifyEnabled: emailVerifyEnabled, RegistrationEmailSuffixWhitelist: ParseRegistrationEmailSuffixWhitelist(settings[SettingKeyRegistrationEmailSuffixWhitelist]), + RegistrationEmailDomainQuotaEnabled: settings[SettingKeyRegistrationEmailDomainQuotaEnabled] == "true", PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true", FrontendURL: settings[SettingKeyFrontendURL], diff --git a/backend/internal/service/setting_public.go b/backend/internal/service/setting_public.go index f81717b11e..f292e8ac57 100644 --- a/backend/internal/service/setting_public.go +++ b/backend/internal/service/setting_public.go @@ -160,6 +160,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyEmailVerifyEnabled, SettingKeyForceEmailOnThirdPartySignup, SettingKeyRegistrationEmailSuffixWhitelist, + SettingKeyRegistrationEmailDomainQuotaEnabled, SettingKeyPromoCodeEnabled, SettingKeyPasswordResetEnabled, SettingKeyInvitationCodeEnabled, @@ -294,60 +295,61 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings } return &PublicSettings{ - RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", - EmailVerifyEnabled: emailVerifyEnabled, - ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true", - RegistrationEmailSuffixWhitelist: registrationEmailSuffixWhitelist, - PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 - PasswordResetEnabled: passwordResetEnabled, - InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", - TotpEnabled: settings[SettingKeyTotpEnabled] == "true", - PasskeyEnabled: s.passkeyConfigured() && s.passkeySettingEnabled(settings), - LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true" && len(loginAgreementDocuments) > 0, - LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]), - LoginAgreementUpdatedAt: loginAgreementUpdatedAt, - LoginAgreementRevision: buildLoginAgreementRevision(loginAgreementUpdatedAt, loginAgreementDocuments), - LoginAgreementDocuments: loginAgreementDocuments, - TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true", - TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey], - TencentCaptchaEnabled: settings[SettingKeyTencentCaptchaEnabled] == "true", - TencentCaptchaAppID: settings[SettingKeyTencentCaptchaAppID], - TencentCaptchaRegion: normalizeTencentCaptchaRegion(settings[SettingKeyTencentCaptchaRegion]), - AliyunCaptchaEnabled: settings[SettingKeyAliyunCaptchaEnabled] == "true", - AliyunCaptchaSceneID: settings[SettingKeyAliyunCaptchaSceneID], - AliyunCaptchaPrefix: settings[SettingKeyAliyunCaptchaPrefix], - AliyunCaptchaRegion: normalizeAliyunCaptchaRegion(settings[SettingKeyAliyunCaptchaRegion]), - SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"), - SiteLogo: settings[SettingKeySiteLogo], - SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"), - APIBaseURL: settings[SettingKeyAPIBaseURL], - ContactInfo: settings[SettingKeyContactInfo], - DocURL: settings[SettingKeyDocURL], - HomeContent: settings[SettingKeyHomeContent], - CompactHomeEnabled: settings[SettingKeyCompactHomeEnabled] == "true", - HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true", - PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true", - PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]), - TableDefaultPageSize: tableDefaultPageSize, - TablePageSizeOptions: tablePageSizeOptions, - CustomMenuItems: settings[SettingKeyCustomMenuItems], - CustomEndpoints: settings[SettingKeyCustomEndpoints], - LinuxDoOAuthEnabled: linuxDoEnabled, - DingTalkOAuthEnabled: dingTalkEnabled, - WeChatOAuthEnabled: weChatEnabled, - WeChatOAuthOpenEnabled: weChatOpenEnabled, - WeChatOAuthMPEnabled: weChatMPEnabled, - WeChatOAuthMobileEnabled: weChatMobileEnabled, - BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true", - PaymentEnabled: settings[SettingPaymentEnabled] == "true", - OIDCOAuthEnabled: oidcEnabled, - OIDCOAuthProviderName: oidcProviderName, - GitHubOAuthEnabled: gitHubEnabled, - GoogleOAuthEnabled: googleEnabled, - BalanceLowNotifyEnabled: settings[SettingKeyBalanceLowNotifyEnabled] == "true", - AccountQuotaNotifyEnabled: settings[SettingKeyAccountQuotaNotifyEnabled] == "true", - BalanceLowNotifyThreshold: balanceLowNotifyThreshold, - BalanceLowNotifyRechargeURL: settings[SettingKeyBalanceLowNotifyRechargeURL], + RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", + EmailVerifyEnabled: emailVerifyEnabled, + ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true", + RegistrationEmailSuffixWhitelist: registrationEmailSuffixWhitelist, + RegistrationEmailDomainQuotaEnabled: settings[SettingKeyRegistrationEmailDomainQuotaEnabled] == "true", + PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 + PasswordResetEnabled: passwordResetEnabled, + InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", + TotpEnabled: settings[SettingKeyTotpEnabled] == "true", + PasskeyEnabled: s.passkeyConfigured() && s.passkeySettingEnabled(settings), + LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true" && len(loginAgreementDocuments) > 0, + LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]), + LoginAgreementUpdatedAt: loginAgreementUpdatedAt, + LoginAgreementRevision: buildLoginAgreementRevision(loginAgreementUpdatedAt, loginAgreementDocuments), + LoginAgreementDocuments: loginAgreementDocuments, + TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true", + TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey], + TencentCaptchaEnabled: settings[SettingKeyTencentCaptchaEnabled] == "true", + TencentCaptchaAppID: settings[SettingKeyTencentCaptchaAppID], + TencentCaptchaRegion: normalizeTencentCaptchaRegion(settings[SettingKeyTencentCaptchaRegion]), + AliyunCaptchaEnabled: settings[SettingKeyAliyunCaptchaEnabled] == "true", + AliyunCaptchaSceneID: settings[SettingKeyAliyunCaptchaSceneID], + AliyunCaptchaPrefix: settings[SettingKeyAliyunCaptchaPrefix], + AliyunCaptchaRegion: normalizeAliyunCaptchaRegion(settings[SettingKeyAliyunCaptchaRegion]), + SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"), + SiteLogo: settings[SettingKeySiteLogo], + SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"), + APIBaseURL: settings[SettingKeyAPIBaseURL], + ContactInfo: settings[SettingKeyContactInfo], + DocURL: settings[SettingKeyDocURL], + HomeContent: settings[SettingKeyHomeContent], + CompactHomeEnabled: settings[SettingKeyCompactHomeEnabled] == "true", + HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true", + PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true", + PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]), + TableDefaultPageSize: tableDefaultPageSize, + TablePageSizeOptions: tablePageSizeOptions, + CustomMenuItems: settings[SettingKeyCustomMenuItems], + CustomEndpoints: settings[SettingKeyCustomEndpoints], + LinuxDoOAuthEnabled: linuxDoEnabled, + DingTalkOAuthEnabled: dingTalkEnabled, + WeChatOAuthEnabled: weChatEnabled, + WeChatOAuthOpenEnabled: weChatOpenEnabled, + WeChatOAuthMPEnabled: weChatMPEnabled, + WeChatOAuthMobileEnabled: weChatMobileEnabled, + BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true", + PaymentEnabled: settings[SettingPaymentEnabled] == "true", + OIDCOAuthEnabled: oidcEnabled, + OIDCOAuthProviderName: oidcProviderName, + GitHubOAuthEnabled: gitHubEnabled, + GoogleOAuthEnabled: googleEnabled, + BalanceLowNotifyEnabled: settings[SettingKeyBalanceLowNotifyEnabled] == "true", + AccountQuotaNotifyEnabled: settings[SettingKeyAccountQuotaNotifyEnabled] == "true", + BalanceLowNotifyThreshold: balanceLowNotifyThreshold, + BalanceLowNotifyRechargeURL: settings[SettingKeyBalanceLowNotifyRechargeURL], ChannelMonitorEnabled: !isFalseSettingValue(settings[SettingKeyChannelMonitorEnabled]), ChannelMonitorMode: normalizeChannelMonitorMode(settings[SettingKeyChannelMonitorMode]), @@ -536,56 +538,57 @@ func (s *SettingService) IsUserErrorViewAllowed(ctx context.Context) bool { // A unit test diffs this struct's JSON keys against dto.PublicSettings to catch // drift automatically (see setting_service_injection_test.go). type PublicSettingsInjectionPayload struct { - RegistrationEnabled bool `json:"registration_enabled"` - EmailVerifyEnabled bool `json:"email_verify_enabled"` - RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` - PromoCodeEnabled bool `json:"promo_code_enabled"` - PasswordResetEnabled bool `json:"password_reset_enabled"` - InvitationCodeEnabled bool `json:"invitation_code_enabled"` - TotpEnabled bool `json:"totp_enabled"` - PasskeyEnabled bool `json:"passkey_enabled"` - LoginAgreementEnabled bool `json:"login_agreement_enabled"` - LoginAgreementMode string `json:"login_agreement_mode"` - LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` - LoginAgreementRevision string `json:"login_agreement_revision"` - LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"` - TurnstileEnabled bool `json:"turnstile_enabled"` - TurnstileSiteKey string `json:"turnstile_site_key"` - TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"` - TencentCaptchaAppID string `json:"tencent_captcha_app_id"` - TencentCaptchaRegion string `json:"tencent_captcha_region"` - AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"` - AliyunCaptchaSceneID string `json:"aliyun_captcha_scene_id"` - AliyunCaptchaPrefix string `json:"aliyun_captcha_prefix"` - AliyunCaptchaRegion string `json:"aliyun_captcha_region"` - SiteName string `json:"site_name"` - SiteLogo string `json:"site_logo"` - SiteSubtitle string `json:"site_subtitle"` - APIBaseURL string `json:"api_base_url"` - ContactInfo string `json:"contact_info"` - DocURL string `json:"doc_url"` - HomeContent string `json:"home_content"` - CompactHomeEnabled bool `json:"compact_home_enabled"` - HideCcsImportButton bool `json:"hide_ccs_import_button"` - PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` - PurchaseSubscriptionURL string `json:"purchase_subscription_url"` - TableDefaultPageSize int `json:"table_default_page_size"` - TablePageSizeOptions []int `json:"table_page_size_options"` - CustomMenuItems json.RawMessage `json:"custom_menu_items"` - CustomEndpoints json.RawMessage `json:"custom_endpoints"` - LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` - DingTalkOAuthEnabled bool `json:"dingtalk_oauth_enabled"` - WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"` - WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"` - WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"` - WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"` - OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"` - OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"` - GitHubOAuthEnabled bool `json:"github_oauth_enabled"` - GoogleOAuthEnabled bool `json:"google_oauth_enabled"` - BackendModeEnabled bool `json:"backend_mode_enabled"` - PaymentEnabled bool `json:"payment_enabled"` - Version string `json:"version"` + RegistrationEnabled bool `json:"registration_enabled"` + EmailVerifyEnabled bool `json:"email_verify_enabled"` + RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` + RegistrationEmailDomainQuotaEnabled bool `json:"registration_email_domain_quota_enabled"` + PromoCodeEnabled bool `json:"promo_code_enabled"` + PasswordResetEnabled bool `json:"password_reset_enabled"` + InvitationCodeEnabled bool `json:"invitation_code_enabled"` + TotpEnabled bool `json:"totp_enabled"` + PasskeyEnabled bool `json:"passkey_enabled"` + LoginAgreementEnabled bool `json:"login_agreement_enabled"` + LoginAgreementMode string `json:"login_agreement_mode"` + LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` + LoginAgreementRevision string `json:"login_agreement_revision"` + LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"` + TurnstileEnabled bool `json:"turnstile_enabled"` + TurnstileSiteKey string `json:"turnstile_site_key"` + TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"` + TencentCaptchaAppID string `json:"tencent_captcha_app_id"` + TencentCaptchaRegion string `json:"tencent_captcha_region"` + AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"` + AliyunCaptchaSceneID string `json:"aliyun_captcha_scene_id"` + AliyunCaptchaPrefix string `json:"aliyun_captcha_prefix"` + AliyunCaptchaRegion string `json:"aliyun_captcha_region"` + SiteName string `json:"site_name"` + SiteLogo string `json:"site_logo"` + SiteSubtitle string `json:"site_subtitle"` + APIBaseURL string `json:"api_base_url"` + ContactInfo string `json:"contact_info"` + DocURL string `json:"doc_url"` + HomeContent string `json:"home_content"` + CompactHomeEnabled bool `json:"compact_home_enabled"` + HideCcsImportButton bool `json:"hide_ccs_import_button"` + PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` + PurchaseSubscriptionURL string `json:"purchase_subscription_url"` + TableDefaultPageSize int `json:"table_default_page_size"` + TablePageSizeOptions []int `json:"table_page_size_options"` + CustomMenuItems json.RawMessage `json:"custom_menu_items"` + CustomEndpoints json.RawMessage `json:"custom_endpoints"` + LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` + DingTalkOAuthEnabled bool `json:"dingtalk_oauth_enabled"` + WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"` + WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"` + WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"` + WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"` + OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"` + OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"` + GitHubOAuthEnabled bool `json:"github_oauth_enabled"` + GoogleOAuthEnabled bool `json:"google_oauth_enabled"` + BackendModeEnabled bool `json:"backend_mode_enabled"` + PaymentEnabled bool `json:"payment_enabled"` + Version string `json:"version"` // 服务器全局时区(IANA 名称与当前 UTC 偏移),高峰时段等服务端本地时间窗口的展示标注用 ServerTimezone string `json:"server_timezone"` ServerUTCOffset string `json:"server_utc_offset"` @@ -620,62 +623,63 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any } return &PublicSettingsInjectionPayload{ - RegistrationEnabled: settings.RegistrationEnabled, - EmailVerifyEnabled: settings.EmailVerifyEnabled, - RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist, - PromoCodeEnabled: settings.PromoCodeEnabled, - PasswordResetEnabled: settings.PasswordResetEnabled, - InvitationCodeEnabled: settings.InvitationCodeEnabled, - TotpEnabled: settings.TotpEnabled, - PasskeyEnabled: settings.PasskeyEnabled, - LoginAgreementEnabled: settings.LoginAgreementEnabled, - LoginAgreementMode: settings.LoginAgreementMode, - LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt, - LoginAgreementRevision: settings.LoginAgreementRevision, - LoginAgreementDocuments: settings.LoginAgreementDocuments, - TurnstileEnabled: settings.TurnstileEnabled, - TurnstileSiteKey: settings.TurnstileSiteKey, - TencentCaptchaEnabled: settings.TencentCaptchaEnabled, - TencentCaptchaAppID: settings.TencentCaptchaAppID, - TencentCaptchaRegion: settings.TencentCaptchaRegion, - AliyunCaptchaEnabled: settings.AliyunCaptchaEnabled, - AliyunCaptchaSceneID: settings.AliyunCaptchaSceneID, - AliyunCaptchaPrefix: settings.AliyunCaptchaPrefix, - AliyunCaptchaRegion: settings.AliyunCaptchaRegion, - SiteName: settings.SiteName, - SiteLogo: settings.SiteLogo, - SiteSubtitle: settings.SiteSubtitle, - APIBaseURL: settings.APIBaseURL, - ContactInfo: settings.ContactInfo, - DocURL: settings.DocURL, - HomeContent: settings.HomeContent, - CompactHomeEnabled: settings.CompactHomeEnabled, - HideCcsImportButton: settings.HideCcsImportButton, - PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, - PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, - TableDefaultPageSize: settings.TableDefaultPageSize, - TablePageSizeOptions: settings.TablePageSizeOptions, - CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems), - CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints), - LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, - DingTalkOAuthEnabled: settings.DingTalkOAuthEnabled, - WeChatOAuthEnabled: settings.WeChatOAuthEnabled, - WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled, - WeChatOAuthMPEnabled: settings.WeChatOAuthMPEnabled, - WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled, - OIDCOAuthEnabled: settings.OIDCOAuthEnabled, - OIDCOAuthProviderName: settings.OIDCOAuthProviderName, - GitHubOAuthEnabled: settings.GitHubOAuthEnabled, - GoogleOAuthEnabled: settings.GoogleOAuthEnabled, - BackendModeEnabled: settings.BackendModeEnabled, - PaymentEnabled: settings.PaymentEnabled, - Version: s.version, - ServerTimezone: timezone.Name(), - ServerUTCOffset: timezone.UTCOffset(), - BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled, - AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled, - BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold, - BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL, + RegistrationEnabled: settings.RegistrationEnabled, + EmailVerifyEnabled: settings.EmailVerifyEnabled, + RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist, + RegistrationEmailDomainQuotaEnabled: settings.RegistrationEmailDomainQuotaEnabled, + PromoCodeEnabled: settings.PromoCodeEnabled, + PasswordResetEnabled: settings.PasswordResetEnabled, + InvitationCodeEnabled: settings.InvitationCodeEnabled, + TotpEnabled: settings.TotpEnabled, + PasskeyEnabled: settings.PasskeyEnabled, + LoginAgreementEnabled: settings.LoginAgreementEnabled, + LoginAgreementMode: settings.LoginAgreementMode, + LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt, + LoginAgreementRevision: settings.LoginAgreementRevision, + LoginAgreementDocuments: settings.LoginAgreementDocuments, + TurnstileEnabled: settings.TurnstileEnabled, + TurnstileSiteKey: settings.TurnstileSiteKey, + TencentCaptchaEnabled: settings.TencentCaptchaEnabled, + TencentCaptchaAppID: settings.TencentCaptchaAppID, + TencentCaptchaRegion: settings.TencentCaptchaRegion, + AliyunCaptchaEnabled: settings.AliyunCaptchaEnabled, + AliyunCaptchaSceneID: settings.AliyunCaptchaSceneID, + AliyunCaptchaPrefix: settings.AliyunCaptchaPrefix, + AliyunCaptchaRegion: settings.AliyunCaptchaRegion, + SiteName: settings.SiteName, + SiteLogo: settings.SiteLogo, + SiteSubtitle: settings.SiteSubtitle, + APIBaseURL: settings.APIBaseURL, + ContactInfo: settings.ContactInfo, + DocURL: settings.DocURL, + HomeContent: settings.HomeContent, + CompactHomeEnabled: settings.CompactHomeEnabled, + HideCcsImportButton: settings.HideCcsImportButton, + PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, + PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, + TableDefaultPageSize: settings.TableDefaultPageSize, + TablePageSizeOptions: settings.TablePageSizeOptions, + CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems), + CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints), + LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, + DingTalkOAuthEnabled: settings.DingTalkOAuthEnabled, + WeChatOAuthEnabled: settings.WeChatOAuthEnabled, + WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled, + WeChatOAuthMPEnabled: settings.WeChatOAuthMPEnabled, + WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled, + OIDCOAuthEnabled: settings.OIDCOAuthEnabled, + OIDCOAuthProviderName: settings.OIDCOAuthProviderName, + GitHubOAuthEnabled: settings.GitHubOAuthEnabled, + GoogleOAuthEnabled: settings.GoogleOAuthEnabled, + BackendModeEnabled: settings.BackendModeEnabled, + PaymentEnabled: settings.PaymentEnabled, + Version: s.version, + ServerTimezone: timezone.Name(), + ServerUTCOffset: timezone.UTCOffset(), + BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled, + AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled, + BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold, + BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL, ChannelMonitorEnabled: settings.ChannelMonitorEnabled, ChannelMonitorMode: settings.ChannelMonitorMode, diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index 1d40f20ca8..f77a914fd7 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -169,6 +169,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting return nil, fmt.Errorf("marshal registration email suffix whitelist: %w", err) } updates[SettingKeyRegistrationEmailSuffixWhitelist] = string(registrationEmailSuffixWhitelistJSON) + updates[SettingKeyRegistrationEmailDomainQuotaEnabled] = strconv.FormatBool(settings.RegistrationEmailDomainQuotaEnabled) updates[SettingKeyPromoCodeEnabled] = strconv.FormatBool(settings.PromoCodeEnabled) updates[SettingKeyPasswordResetEnabled] = strconv.FormatBool(settings.PasswordResetEnabled) updates[SettingKeyFrontendURL] = settings.FrontendURL diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 3ccdd02398..8580f0c8fb 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -12,22 +12,23 @@ func firstNonEmpty(values ...string) string { } type SystemSettings struct { - RegistrationEnabled bool - EmailVerifyEnabled bool - RegistrationEmailSuffixWhitelist []string - PromoCodeEnabled bool - PasswordResetEnabled bool - FrontendURL string - InvitationCodeEnabled bool - TotpEnabled bool // TOTP 双因素认证 - PasskeyEnabled bool // Passkey 登录 - SessionBindingEnabled bool // 会话 IP/UA 绑定(变更即失效) - StepUpEnabled bool // 敏感操作 step-up 2FA 门控 - AuditLogRetentionDays int // 审计日志保留天数(<=0 永久保留) - LoginAgreementEnabled bool - LoginAgreementMode string - LoginAgreementUpdatedAt string - LoginAgreementDocuments []LoginAgreementDocument + RegistrationEnabled bool + EmailVerifyEnabled bool + RegistrationEmailSuffixWhitelist []string + RegistrationEmailDomainQuotaEnabled bool // 白名单非空时放行非白名单域名限量注册(默认关闭) + PromoCodeEnabled bool + PasswordResetEnabled bool + FrontendURL string + InvitationCodeEnabled bool + TotpEnabled bool // TOTP 双因素认证 + PasskeyEnabled bool // Passkey 登录 + SessionBindingEnabled bool // 会话 IP/UA 绑定(变更即失效) + StepUpEnabled bool // 敏感操作 step-up 2FA 门控 + AuditLogRetentionDays int // 审计日志保留天数(<=0 永久保留) + LoginAgreementEnabled bool + LoginAgreementMode string + LoginAgreementUpdatedAt string + LoginAgreementDocuments []LoginAgreementDocument SMTPHost string SMTPPort int @@ -312,38 +313,39 @@ type DefaultSubscriptionSetting struct { } type PublicSettings struct { - RegistrationEnabled bool - EmailVerifyEnabled bool - ForceEmailOnThirdPartySignup bool - RegistrationEmailSuffixWhitelist []string - PromoCodeEnabled bool - PasswordResetEnabled bool - InvitationCodeEnabled bool - TotpEnabled bool // TOTP 双因素认证 - PasskeyEnabled bool - LoginAgreementEnabled bool - LoginAgreementMode string - LoginAgreementUpdatedAt string - LoginAgreementRevision string - LoginAgreementDocuments []LoginAgreementDocument - TurnstileEnabled bool - TurnstileSiteKey string - TencentCaptchaEnabled bool - TencentCaptchaAppID string - TencentCaptchaRegion string - AliyunCaptchaEnabled bool - AliyunCaptchaSceneID string - AliyunCaptchaPrefix string - AliyunCaptchaRegion string - SiteName string - SiteLogo string - SiteSubtitle string - APIBaseURL string - ContactInfo string - DocURL string - HomeContent string - CompactHomeEnabled bool - HideCcsImportButton bool + RegistrationEnabled bool + EmailVerifyEnabled bool + ForceEmailOnThirdPartySignup bool + RegistrationEmailSuffixWhitelist []string + RegistrationEmailDomainQuotaEnabled bool + PromoCodeEnabled bool + PasswordResetEnabled bool + InvitationCodeEnabled bool + TotpEnabled bool // TOTP 双因素认证 + PasskeyEnabled bool + LoginAgreementEnabled bool + LoginAgreementMode string + LoginAgreementUpdatedAt string + LoginAgreementRevision string + LoginAgreementDocuments []LoginAgreementDocument + TurnstileEnabled bool + TurnstileSiteKey string + TencentCaptchaEnabled bool + TencentCaptchaAppID string + TencentCaptchaRegion string + AliyunCaptchaEnabled bool + AliyunCaptchaSceneID string + AliyunCaptchaPrefix string + AliyunCaptchaRegion string + SiteName string + SiteLogo string + SiteSubtitle string + APIBaseURL string + ContactInfo string + DocURL string + HomeContent string + CompactHomeEnabled bool + HideCcsImportButton bool PurchaseSubscriptionEnabled bool PurchaseSubscriptionURL string diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 08629df300..b176024ae6 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -392,6 +392,7 @@ export interface SystemSettings { registration_enabled: boolean; email_verify_enabled: boolean; registration_email_suffix_whitelist: string[]; + registration_email_domain_quota_enabled: boolean; promo_code_enabled: boolean; password_reset_enabled: boolean; frontend_url: string; @@ -734,6 +735,7 @@ export interface UpdateSettingsRequest { registration_enabled?: boolean; email_verify_enabled?: boolean; registration_email_suffix_whitelist?: string[]; + registration_email_domain_quota_enabled?: boolean; promo_code_enabled?: boolean; password_reset_enabled?: boolean; frontend_url?: string; diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index 32a282c5ca..b20e2ebeee 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -128,9 +128,12 @@ export default { emailVerificationHint: 'Require email verification for new registrations', emailSuffixWhitelist: 'Email Domain Whitelist', emailSuffixWhitelistHint: - "Emails from allowlist domains can register without a quota. When the allowlist is not empty, every other registrable domain can register one account. Empty the allowlist to remove the quota for all domains (for example, {'@'}qq.com, {'@'}gmail.com, *.edu.cn).", + "Only email addresses from the specified domains can register; leave empty for no restriction (for example, {'@'}qq.com, {'@'}gmail.com, *.edu.cn)", emailSuffixWhitelistPlaceholder: "{'@'}example.com, *.edu.cn", - emailSuffixWhitelistInputHint: 'Empty the allowlist to remove the registration quota. Use *.edu.cn to match edu.cn and its subdomains.', + emailSuffixWhitelistInputHint: 'Leave empty for no restriction. Use *.edu.cn to match edu.cn and its subdomains.', + emailDomainQuota: 'Non-allowlist Domain Quota', + emailDomainQuotaHint: + 'When enabled and the allowlist is not empty, every other registrable domain can register one account. When disabled, non-allowlist domains are rejected. Has no effect while the allowlist is empty', promoCode: 'Promo Code', promoCodeHint: 'Allow users to use promo codes during registration', invitationCode: 'Invitation Code Registration', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index 2782dae7c3..3967f25ed7 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -128,9 +128,12 @@ export default { emailVerificationHint: '新用户注册时需要验证邮箱', emailSuffixWhitelist: '邮箱域名白名单', emailSuffixWhitelistHint: - "白名单域名的邮箱可无限注册;白名单非空时,其他可注册主域名各限注册一个账户。清空白名单后,所有域名均不限制注册数量(例如 {'@'}qq.com, {'@'}gmail.com, *.edu.cn)", + "仅允许使用指定域名的邮箱注册账号;留空则不限制(例如 {'@'}qq.com, {'@'}gmail.com, *.edu.cn)", emailSuffixWhitelistPlaceholder: "{'@'}example.com, *.edu.cn", - emailSuffixWhitelistInputHint: '清空白名单后不限制注册数量。使用 *.edu.cn 可匹配 edu.cn 及其子域名。', + emailSuffixWhitelistInputHint: '留空则不限制。使用 *.edu.cn 可匹配 edu.cn 及其子域名。', + emailDomainQuota: '非白名单域名限量注册', + emailDomainQuotaHint: + '开启后,白名单非空时,其他可注册主域名各限注册一个账户;关闭时非白名单域名直接拒绝注册。白名单为空时本开关无效果', promoCode: '优惠码', promoCodeHint: '允许用户在注册时使用优惠码', invitationCode: '邀请码注册', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index a1a248a5af..24498cf7ca 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -209,6 +209,7 @@ export interface PublicSettings { email_verify_enabled: boolean force_email_on_third_party_signup: boolean registration_email_suffix_whitelist: string[] + registration_email_domain_quota_enabled?: boolean promo_code_enabled: boolean password_reset_enabled: boolean invitation_code_enabled: boolean diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 609e4260c2..b153865ff3 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -1486,6 +1486,23 @@

+ +
+
+ +

+ {{ t("admin.settings.registration.emailDomainQuotaHint") }} +

+
+ +
+
({ registration_enabled: true, email_verify_enabled: false, registration_email_suffix_whitelist: [], + registration_email_domain_quota_enabled: false, promo_code_enabled: true, invitation_code_enabled: false, password_reset_enabled: false, @@ -11023,6 +11041,8 @@ async function saveSettings() { registrationEmailSuffixWhitelistTags.value.map((suffix) => suffix.startsWith("*.") ? suffix : `@${suffix}`, ), + registration_email_domain_quota_enabled: + form.registration_email_domain_quota_enabled, promo_code_enabled: form.promo_code_enabled, invitation_code_enabled: form.invitation_code_enabled, password_reset_enabled: form.password_reset_enabled, diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts index a6c520ebe3..aa81d8f476 100644 --- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts +++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts @@ -605,12 +605,18 @@ describe("admin SettingsView email domain quota copy", () => { expect(enCommon.auth.emailDomainRegistrationLimit).toContain("mainstream email"); expect(enCommon.auth.emailDomainRegistrationLimit).toContain("contact support"); - const zhHint = zhSettings.settings.registration.emailSuffixWhitelistHint; - const enHint = enSettings.settings.registration.emailSuffixWhitelistHint; - expect(zhHint).toContain("其他可注册主域名各限注册一个账户"); - expect(zhHint).toContain("清空白名单"); - expect(enHint).toContain("one account"); - expect(enHint).toContain("empty"); + // 白名单 hint 描述严格默认语义;额度语义移入独立开关的 hint。 + const zhWhitelistHint = zhSettings.settings.registration.emailSuffixWhitelistHint; + const enWhitelistHint = enSettings.settings.registration.emailSuffixWhitelistHint; + expect(zhWhitelistHint).toContain("留空则不限制"); + expect(enWhitelistHint).toContain("leave empty for no restriction"); + + const zhQuotaHint = zhSettings.settings.registration.emailDomainQuotaHint; + const enQuotaHint = enSettings.settings.registration.emailDomainQuotaHint; + expect(zhQuotaHint).toContain("其他可注册主域名各限注册一个账户"); + expect(zhQuotaHint).toContain("关闭时非白名单域名直接拒绝"); + expect(enQuotaHint).toContain("one account"); + expect(enQuotaHint).toContain("When disabled"); }); }); diff --git a/frontend/src/views/auth/EmailVerifyView.vue b/frontend/src/views/auth/EmailVerifyView.vue index eab23e43c3..52198961e6 100644 --- a/frontend/src/views/auth/EmailVerifyView.vue +++ b/frontend/src/views/auth/EmailVerifyView.vue @@ -196,13 +196,18 @@ import { import { apiClient } from '@/api/client' import { buildAuthErrorMessage } from '@/utils/authError' import { extractApiErrorCode } from '@/utils/apiError' +import { + formatRegistrationEmailSuffixWhitelistForMessage, + isRegistrationEmailSuffixAllowed, + normalizeRegistrationEmailSuffixWhitelist +} from '@/utils/registrationEmailPolicy' import { clearAllAffiliateReferralCodes, loadAffiliateReferralCode, oauthAffiliatePayload } from '@/utils/oauthAffiliate' -const { t } = useI18n() +const { t, locale } = useI18n() // ==================== Router & Stores ==================== @@ -266,6 +271,9 @@ const aliyunCaptchaSceneId = ref('') const aliyunCaptchaPrefix = ref('') const aliyunCaptchaRegion = ref('cn') const siteName = ref('Sub2API') +const registrationEmailSuffixWhitelist = ref([]) +// 域名限量注册开关:开启时非白名单域名可注册 1 个账户(由后端判定),前端不做白名单预检。 +const emailDomainQuotaEnabled = ref(false) // Turnstile for resend const turnstileRef = ref | null>(null) @@ -365,6 +373,10 @@ onMounted(async () => { aliyunCaptchaPrefix.value = settings.aliyun_captcha_prefix || '' aliyunCaptchaRegion.value = settings.aliyun_captcha_region || 'cn' siteName.value = settings.site_name || 'Sub2API' + registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist( + settings.registration_email_suffix_whitelist || [] + ) + emailDomainQuotaEnabled.value = settings.registration_email_domain_quota_enabled === true } catch (error) { console.error('Failed to load public settings:', error) } @@ -473,6 +485,13 @@ function isPendingOAuthFlow(): boolean { return Boolean(pendingProvider.value.trim()) } +// 域名限量注册开启时交由后端按额度判定;pending OAuth / 换绑流程沿用后端策略,前端不预检。 +function shouldBypassRegistrationEmailPolicy(): boolean { + return ( + emailDomainQuotaEnabled.value || isPendingOAuthFlow() || Boolean(pendingAuthToken.value.trim()) + ) +} + function resolvePendingOAuthCallbackRoute(provider: string): string { switch (provider.trim().toLowerCase()) { case 'linuxdo': @@ -514,6 +533,12 @@ async function sendCode(): Promise { let captchaProofUsed = false try { + if (!shouldBypassRegistrationEmailPolicy() && !isRegistrationEmailSuffixAllowed(email.value, registrationEmailSuffixWhitelist.value)) { + errorMessage.value = buildEmailSuffixNotAllowedMessage() + appStore.showError(errorMessage.value) + return + } + const requestPayload = { email: email.value, [pendingAuthTokenField.value]: pendingAuthToken.value || undefined, @@ -635,6 +660,12 @@ async function handleVerify(): Promise { return } + if (!shouldBypassRegistrationEmailPolicy() && !isRegistrationEmailSuffixAllowed(email.value, registrationEmailSuffixWhitelist.value)) { + errorMessage.value = buildEmailSuffixNotAllowedMessage() + appStore.showError(errorMessage.value) + return + } + if (!(await acquireCreateAccountActionProof())) { return } @@ -735,6 +766,22 @@ function handleBack(): void { router.push('/register') } +function buildEmailSuffixNotAllowedMessage(): string { + const normalizedWhitelist = normalizeRegistrationEmailSuffixWhitelist( + registrationEmailSuffixWhitelist.value + ) + if (normalizedWhitelist.length === 0) { + return t('auth.emailSuffixNotAllowed') + } + const separator = String(locale.value || '').toLowerCase().startsWith('zh') ? '、' : ', ' + return t('auth.emailSuffixNotAllowedWithAllowed', { + suffixes: formatRegistrationEmailSuffixWhitelistForMessage(normalizedWhitelist, { + separator, + more: (count) => t('auth.emailSuffixAllowedMore', { count }) + }) + }) +} + function buildRegistrationErrorMessage(error: unknown, fallback: string): string { if (extractApiErrorCode(error) === 'EMAIL_DOMAIN_REGISTRATION_LIMIT') { return t('auth.emailDomainRegistrationLimit') diff --git a/frontend/src/views/auth/RegisterView.vue b/frontend/src/views/auth/RegisterView.vue index d6a19213aa..c4ccd9c98f 100644 --- a/frontend/src/views/auth/RegisterView.vue +++ b/frontend/src/views/auth/RegisterView.vue @@ -354,6 +354,11 @@ import { } from '@/api/auth' import { buildAuthErrorMessage } from '@/utils/authError' import { extractApiErrorCode, extractI18nErrorMessage } from '@/utils/apiError' +import { + formatRegistrationEmailSuffixWhitelistForMessage, + isRegistrationEmailSuffixAllowed, + normalizeRegistrationEmailSuffixWhitelist +} from '@/utils/registrationEmailPolicy' import { clearAffiliateReferralCode, loadAffiliateReferralCode, @@ -361,7 +366,7 @@ import { } from '@/utils/oauthAffiliate' import type { LoginAgreementDocument } from '@/types' -const { t } = useI18n() +const { t, locale } = useI18n() const LOGIN_AGREEMENT_STORAGE_KEY = 'sub2api_login_agreement_consent' // ==================== Router & Stores ==================== @@ -400,6 +405,9 @@ const oidcOAuthEnabled = ref(false) const oidcOAuthProviderName = ref('OIDC') const githubOAuthEnabled = ref(false) const googleOAuthEnabled = ref(false) +const registrationEmailSuffixWhitelist = ref([]) +// 域名限量注册开关:开启时非白名单域名可注册 1 个账户(由后端判定),前端不做白名单预检。 +const emailDomainQuotaEnabled = ref(false) const loginAgreementEnabled = ref(false) const loginAgreementMode = ref<'modal' | 'checkbox' | string>('modal') const loginAgreementUpdatedAt = ref('') @@ -532,6 +540,10 @@ onMounted(async () => { oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC' githubOAuthEnabled.value = settings.github_oauth_enabled googleOAuthEnabled.value = settings.google_oauth_enabled + registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist( + settings.registration_email_suffix_whitelist || [] + ) + emailDomainQuotaEnabled.value = settings.registration_email_domain_quota_enabled === true applyLoginAgreementSettings(settings) // Read promo code from URL parameter only if promo code is enabled @@ -850,6 +862,22 @@ function validateEmail(email: string): boolean { return emailRegex.test(email) } +function buildEmailSuffixNotAllowedMessage(): string { + const normalizedWhitelist = normalizeRegistrationEmailSuffixWhitelist( + registrationEmailSuffixWhitelist.value + ) + if (normalizedWhitelist.length === 0) { + return t('auth.emailSuffixNotAllowed') + } + const separator = String(locale.value || '').toLowerCase().startsWith('zh') ? '、' : ', ' + return t('auth.emailSuffixNotAllowedWithAllowed', { + suffixes: formatRegistrationEmailSuffixWhitelistForMessage(normalizedWhitelist, { + separator, + more: (count) => t('auth.emailSuffixAllowedMore', { count }) + }) + }) +} + function validateForm(): boolean { // Reset errors errors.email = '' @@ -874,6 +902,13 @@ function validateForm(): boolean { } else if (!validateEmail(formData.email)) { errors.email = t('auth.invalidEmail') isValid = false + } else if ( + !emailDomainQuotaEnabled.value && + !isRegistrationEmailSuffixAllowed(formData.email, registrationEmailSuffixWhitelist.value) + ) { + // 域名限量注册关闭时保持严格白名单预检;开启时交给后端按域名额度判定 + errors.email = buildEmailSuffixNotAllowedMessage() + isValid = false } // Password validation diff --git a/frontend/src/views/auth/__tests__/EmailVerifyView.spec.ts b/frontend/src/views/auth/__tests__/EmailVerifyView.spec.ts index ad45e1d01c..fc2d9715af 100644 --- a/frontend/src/views/auth/__tests__/EmailVerifyView.spec.ts +++ b/frontend/src/views/auth/__tests__/EmailVerifyView.spec.ts @@ -316,6 +316,7 @@ describe('EmailVerifyView', () => { turnstile_site_key: '', site_name: 'Sub2API', registration_email_suffix_whitelist: ['allowed.com'], + registration_email_domain_quota_enabled: true, }) sessionStorage.setItem( 'register_data', @@ -350,6 +351,7 @@ describe('EmailVerifyView', () => { turnstile_site_key: '', site_name: 'Sub2API', registration_email_suffix_whitelist: ['allowed.com'], + registration_email_domain_quota_enabled: true, }) sendVerifyCodeMock.mockRejectedValueOnce({ reason: 'EMAIL_DOMAIN_REGISTRATION_LIMIT', @@ -387,6 +389,7 @@ describe('EmailVerifyView', () => { turnstile_site_key: '', site_name: 'Sub2API', registration_email_suffix_whitelist: ['allowed.com'], + registration_email_domain_quota_enabled: true, }) sessionStorage.setItem( 'register_data', @@ -422,6 +425,39 @@ describe('EmailVerifyView', () => { ) }) + // 域名限量注册开关默认关闭:恢复 PR5423 之前的客户端白名单预检,非白名单域名不发送验证码。 + it('blocks sending a verification code for a non-whitelist email domain when the quota switch is disabled', async () => { + getPublicSettingsMock.mockResolvedValue({ + turnstile_enabled: false, + turnstile_site_key: '', + site_name: 'Sub2API', + registration_email_suffix_whitelist: ['allowed.com'], + }) + sessionStorage.setItem( + 'register_data', + JSON.stringify({ + email: 'first@custom.example', + password: 'secret-123', + }) + ) + + mount(EmailVerifyView, { + global: { + stubs: { + AuthLayout: { template: '
' }, + Icon: true, + TurnstileWidget: true, + transition: false, + }, + }, + }) + + await flushPromises() + + expect(sendVerifyCodeMock).not.toHaveBeenCalled() + expect(showErrorMock).toHaveBeenCalledWith('auth.emailSuffixNotAllowedWithAllowed') + }) + it('uses the pending oauth verify-code endpoint when auth store only carries the pending provider', async () => { authStoreState.pendingAuthSession = { token: '', diff --git a/frontend/src/views/auth/__tests__/RegisterView.spec.ts b/frontend/src/views/auth/__tests__/RegisterView.spec.ts index 0dc7640da9..acd45c2728 100644 --- a/frontend/src/views/auth/__tests__/RegisterView.spec.ts +++ b/frontend/src/views/auth/__tests__/RegisterView.spec.ts @@ -122,7 +122,8 @@ describe('RegisterView invitation layout', () => { getPublicSettingsMock.mockResolvedValueOnce({ ...publicSettings, turnstile_enabled: false, - registration_email_suffix_whitelist: ['allowed.com'] + registration_email_suffix_whitelist: ['allowed.com'], + registration_email_domain_quota_enabled: true }) const wrapper = mountRegister() @@ -142,7 +143,8 @@ describe('RegisterView invitation layout', () => { getPublicSettingsMock.mockResolvedValueOnce({ ...publicSettings, turnstile_enabled: false, - registration_email_suffix_whitelist: ['allowed.com'] + registration_email_suffix_whitelist: ['allowed.com'], + registration_email_domain_quota_enabled: true }) registerMock.mockRejectedValueOnce({ reason: 'EMAIL_DOMAIN_REGISTRATION_LIMIT', @@ -160,4 +162,45 @@ describe('RegisterView invitation layout', () => { '该邮箱域名无法注册新账户。请使用主流邮箱注册;如需使用企业邮箱,请联系客服添加域名白名单。' ) }) + + // 域名限量注册开关默认关闭:恢复 PR5423 之前的客户端白名单预检,非白名单域名不发起注册请求。 + it('rejects a non-whitelist email domain locally when the domain quota switch is disabled', async () => { + getPublicSettingsMock.mockResolvedValueOnce({ + ...publicSettings, + turnstile_enabled: false, + registration_email_suffix_whitelist: ['allowed.com'] + }) + + const wrapper = mountRegister() + await flushPromises() + await wrapper.get('#email').setValue('first@custom.example') + await wrapper.get('#password').setValue('secret-123') + await wrapper.get('form').trigger('submit.prevent') + await flushPromises() + + expect(registerMock).not.toHaveBeenCalled() + // 校验失败通过 validationToastMessage watcher 弹 toast + expect(showErrorMock).toHaveBeenCalledWith('auth.emailSuffixNotAllowedWithAllowed') + expect(wrapper.get('#email').classes()).toContain('input-error') + }) + + it('still submits whitelisted email domains when the domain quota switch is disabled', async () => { + getPublicSettingsMock.mockResolvedValueOnce({ + ...publicSettings, + turnstile_enabled: false, + registration_email_suffix_whitelist: ['allowed.com'] + }) + + const wrapper = mountRegister() + await flushPromises() + await wrapper.get('#email').setValue('user@allowed.com') + await wrapper.get('#password').setValue('secret-123') + await wrapper.get('form').trigger('submit.prevent') + await flushPromises() + + expect(registerMock).toHaveBeenCalledWith( + expect.objectContaining({ email: 'user@allowed.com' }) + ) + expect(showErrorMock).not.toHaveBeenCalled() + }) })