feat: add default-off switch for email domain registration quota

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.
This commit is contained in:
shaw
2026-08-09 15:53:40 +08:00
parent f2da30bcd9
commit 563a72ca73
27 changed files with 836 additions and 444 deletions
@@ -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,
@@ -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")
}
@@ -26,6 +26,7 @@ type UpdateSettingsRequest struct {
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"`
@@ -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 {
@@ -1492,6 +1497,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
RegistrationEnabled: req.RegistrationEnabled,
EmailVerifyEnabled: req.EmailVerifyEnabled,
RegistrationEmailSuffixWhitelist: req.RegistrationEmailSuffixWhitelist,
RegistrationEmailDomainQuotaEnabled: registrationEmailDomainQuotaEnabled,
PromoCodeEnabled: req.PromoCodeEnabled,
PasswordResetEnabled: req.PasswordResetEnabled,
FrontendURL: req.FrontendURL,
@@ -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,
@@ -1474,6 +1474,7 @@ func TestCreateOIDCOAuthAccountRejectsSecondEmailOutsideRegistrationSuffixWhitel
},
settingValues: map[string]string{
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()
+2
View File
@@ -30,6 +30,7 @@ type SystemSettings struct {
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"`
@@ -351,6 +352,7 @@ type PublicSettings struct {
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"`
@@ -48,6 +48,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
EmailVerifyEnabled: settings.EmailVerifyEnabled,
ForceEmailOnThirdPartySignup: settings.ForceEmailOnThirdPartySignup,
RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist,
RegistrationEmailDomainQuotaEnabled: settings.RegistrationEmailDomainQuotaEnabled,
PromoCodeEnabled: settings.PromoCodeEnabled,
PasswordResetEnabled: settings.PasswordResetEnabled,
InvitationCodeEnabled: settings.InvitationCodeEnabled,
@@ -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": "",
@@ -208,6 +208,7 @@ func TestRegisterOAuthEmailAccount_NonWhitelistDomainLimit(t *testing.T) {
map[string]string{
SettingKeyRegistrationEnabled: "true",
SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`,
SettingKeyRegistrationEmailDomainQuotaEnabled: "true",
},
&emailCacheStub{data: &VerificationCodeData{
Code: "246810",
@@ -238,6 +239,7 @@ func TestRegisterVerifiedOAuthEmailAccount_NonWhitelistDomainLimit(t *testing.T)
map[string]string{
SettingKeyRegistrationEnabled: "true",
SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`,
SettingKeyRegistrationEmailDomainQuotaEnabled: "true",
},
&emailCacheStub{},
nil,
@@ -263,6 +265,7 @@ func TestSendPendingOAuthVerifyCode_NonWhitelistDomainLimit(t *testing.T) {
map[string]string{
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
+8
View File
@@ -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)
}
@@ -418,6 +418,7 @@ func TestAuthService_Register_EmailSuffixNotAllowed(t *testing.T) {
service := newAuthService(repo, map[string]string{
SettingKeyRegistrationEnabled: "true",
SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com","@company.com"]`,
SettingKeyRegistrationEmailDomainQuotaEnabled: "true",
}, nil, nil)
_, _, err := service.Register(context.Background(), "user@other.com", "password")
@@ -432,6 +433,7 @@ func TestAuthService_Register_NonWhitelistDomainAllowsFirstAccount(t *testing.T)
svc := newAuthService(repo, map[string]string{
SettingKeyRegistrationEnabled: "true",
SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`,
SettingKeyRegistrationEmailDomainQuotaEnabled: "true",
}, nil, nil)
_, user, err := svc.Register(context.Background(), "first@custom.example", "password")
@@ -444,12 +446,79 @@ func TestAuthService_Register_NonWhitelistDomainRejectsSecondAccount(t *testing.
svc := newAuthService(repo, map[string]string{
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{
@@ -479,6 +548,7 @@ func TestAuthService_SendVerifyCode_EmailSuffixNotAllowed(t *testing.T) {
service := newAuthService(repo, map[string]string{
SettingKeyRegistrationEnabled: "true",
SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com","@company.com"]`,
SettingKeyRegistrationEmailDomainQuotaEnabled: "true",
}, nil, nil)
err := service.SendVerifyCode(context.Background(), "user@other.com")
@@ -492,6 +562,7 @@ func TestAuthService_SendVerifyCode_NonWhitelistDomainLimit(t *testing.T) {
svc := newAuthService(repo, map[string]string{
SettingKeyRegistrationEnabled: "true",
SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`,
SettingKeyRegistrationEmailDomainQuotaEnabled: "true",
}, nil, nil)
err := svc.SendVerifyCode(context.Background(), "user@custom.example")
@@ -503,6 +574,7 @@ func TestAuthService_SendVerifyCodeAsync_NonWhitelistDomainLimit(t *testing.T) {
svc := newAuthService(repo, map[string]string{
SettingKeyRegistrationEnabled: "true",
SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`,
SettingKeyRegistrationEmailDomainQuotaEnabled: "true",
}, nil, nil)
_, err := svc.SendVerifyCodeAsync(context.Background(), "user@custom.example")
@@ -142,6 +142,9 @@ const (
SettingKeyRegistrationEnabled = "registration_enabled" // 是否开放注册
SettingKeyEmailVerifyEnabled = "email_verify_enabled" // 是否开启邮件验证
SettingKeyRegistrationEmailSuffixWhitelist = "registration_email_suffix_whitelist" // 注册邮箱后缀白名单(JSON 数组)
// 白名单非空时,是否放行非白名单域名按主域名限量注册(每域名 1 个账户)。
// 默认 false:非白名单域名直接拒绝(白名单严格模式)。
SettingKeyRegistrationEmailDomainQuotaEnabled = "registration_email_domain_quota_enabled"
SettingKeyPromoCodeEnabled = "promo_code_enabled" // 是否启用优惠码功能
SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证)
SettingKeyFrontendURL = "frontend_url" // 前端基础URL,用于生成邮件中的重置密码链接
@@ -50,6 +50,7 @@ func TestRegistrationEmailQuotaRejectsMalformedDomainWhenWhitelistConfigured(t *
svc := newAuthService(repo, map[string]string{
SettingKeyRegistrationEnabled: "true",
SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`,
SettingKeyRegistrationEmailDomainQuotaEnabled: "true",
}, nil, nil)
_, _, err := svc.Register(context.Background(), "malformed-email", "password")
@@ -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)
@@ -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],
@@ -160,6 +160,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyEmailVerifyEnabled,
SettingKeyForceEmailOnThirdPartySignup,
SettingKeyRegistrationEmailSuffixWhitelist,
SettingKeyRegistrationEmailDomainQuotaEnabled,
SettingKeyPromoCodeEnabled,
SettingKeyPasswordResetEnabled,
SettingKeyInvitationCodeEnabled,
@@ -298,6 +299,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
EmailVerifyEnabled: emailVerifyEnabled,
ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true",
RegistrationEmailSuffixWhitelist: registrationEmailSuffixWhitelist,
RegistrationEmailDomainQuotaEnabled: settings[SettingKeyRegistrationEmailDomainQuotaEnabled] == "true",
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
PasswordResetEnabled: passwordResetEnabled,
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
@@ -539,6 +541,7 @@ type PublicSettingsInjectionPayload struct {
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"`
@@ -623,6 +626,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
RegistrationEnabled: settings.RegistrationEnabled,
EmailVerifyEnabled: settings.EmailVerifyEnabled,
RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist,
RegistrationEmailDomainQuotaEnabled: settings.RegistrationEmailDomainQuotaEnabled,
PromoCodeEnabled: settings.PromoCodeEnabled,
PasswordResetEnabled: settings.PasswordResetEnabled,
InvitationCodeEnabled: settings.InvitationCodeEnabled,
@@ -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
@@ -15,6 +15,7 @@ type SystemSettings struct {
RegistrationEnabled bool
EmailVerifyEnabled bool
RegistrationEmailSuffixWhitelist []string
RegistrationEmailDomainQuotaEnabled bool // 白名单非空时放行非白名单域名限量注册(默认关闭)
PromoCodeEnabled bool
PasswordResetEnabled bool
FrontendURL string
@@ -316,6 +317,7 @@ type PublicSettings struct {
EmailVerifyEnabled bool
ForceEmailOnThirdPartySignup bool
RegistrationEmailSuffixWhitelist []string
RegistrationEmailDomainQuotaEnabled bool
PromoCodeEnabled bool
PasswordResetEnabled bool
InvitationCodeEnabled bool
+2
View File
@@ -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;
@@ -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',
@@ -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: '邀请码注册',
+1
View File
@@ -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
+20
View File
@@ -1486,6 +1486,23 @@
</p>
</div>
<!-- Email Domain Quota -->
<div
class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
>
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t("admin.settings.registration.emailDomainQuota")
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.registration.emailDomainQuotaHint") }}
</p>
</div>
<Toggle
v-model="form.registration_email_domain_quota_enabled"
/>
</div>
<!-- Promo Code -->
<div
class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
@@ -9401,6 +9418,7 @@ const form = reactive<SettingsForm>({
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,
@@ -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");
});
});
+48 -1
View File
@@ -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<string>('')
const aliyunCaptchaPrefix = ref<string>('')
const aliyunCaptchaRegion = ref<string>('cn')
const siteName = ref<string>('Sub2API')
const registrationEmailSuffixWhitelist = ref<string[]>([])
// 域名限量注册开关:开启时非白名单域名可注册 1 个账户(由后端判定),前端不做白名单预检。
const emailDomainQuotaEnabled = ref<boolean>(false)
// Turnstile for resend
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | 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<void> {
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<void> {
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')
+36 -1
View File
@@ -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<boolean>(false)
const oidcOAuthProviderName = ref<string>('OIDC')
const githubOAuthEnabled = ref<boolean>(false)
const googleOAuthEnabled = ref<boolean>(false)
const registrationEmailSuffixWhitelist = ref<string[]>([])
// 域名限量注册开关:开启时非白名单域名可注册 1 个账户(由后端判定),前端不做白名单预检。
const emailDomainQuotaEnabled = ref<boolean>(false)
const loginAgreementEnabled = ref<boolean>(false)
const loginAgreementMode = ref<'modal' | 'checkbox' | string>('modal')
const loginAgreementUpdatedAt = ref<string>('')
@@ -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
@@ -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: '<div><slot /><slot name="footer" /></div>' },
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: '',
@@ -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()
})
})