feat(registration): add email domain whitelist policy

This commit is contained in:
PMExtra
2026-03-02 23:13:39 +08:00
parent ba6de4c4d4
commit bd0801a887
25 changed files with 1113 additions and 267 deletions

View File

@@ -77,6 +77,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
response.Success(c, dto.SystemSettings{ response.Success(c, dto.SystemSettings{
RegistrationEnabled: settings.RegistrationEnabled, RegistrationEnabled: settings.RegistrationEnabled,
EmailVerifyEnabled: settings.EmailVerifyEnabled, EmailVerifyEnabled: settings.EmailVerifyEnabled,
RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist,
PromoCodeEnabled: settings.PromoCodeEnabled, PromoCodeEnabled: settings.PromoCodeEnabled,
PasswordResetEnabled: settings.PasswordResetEnabled, PasswordResetEnabled: settings.PasswordResetEnabled,
InvitationCodeEnabled: settings.InvitationCodeEnabled, InvitationCodeEnabled: settings.InvitationCodeEnabled,
@@ -130,12 +131,13 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
// UpdateSettingsRequest 更新设置请求 // UpdateSettingsRequest 更新设置请求
type UpdateSettingsRequest struct { type UpdateSettingsRequest struct {
// 注册设置 // 注册设置
RegistrationEnabled bool `json:"registration_enabled"` RegistrationEnabled bool `json:"registration_enabled"`
EmailVerifyEnabled bool `json:"email_verify_enabled"` EmailVerifyEnabled bool `json:"email_verify_enabled"`
PromoCodeEnabled bool `json:"promo_code_enabled"` RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
PasswordResetEnabled bool `json:"password_reset_enabled"` PromoCodeEnabled bool `json:"promo_code_enabled"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"` PasswordResetEnabled bool `json:"password_reset_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
// 邮件服务设置 // 邮件服务设置
SMTPHost string `json:"smtp_host"` SMTPHost string `json:"smtp_host"`
@@ -426,50 +428,51 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
} }
settings := &service.SystemSettings{ settings := &service.SystemSettings{
RegistrationEnabled: req.RegistrationEnabled, RegistrationEnabled: req.RegistrationEnabled,
EmailVerifyEnabled: req.EmailVerifyEnabled, EmailVerifyEnabled: req.EmailVerifyEnabled,
PromoCodeEnabled: req.PromoCodeEnabled, RegistrationEmailSuffixWhitelist: req.RegistrationEmailSuffixWhitelist,
PasswordResetEnabled: req.PasswordResetEnabled, PromoCodeEnabled: req.PromoCodeEnabled,
InvitationCodeEnabled: req.InvitationCodeEnabled, PasswordResetEnabled: req.PasswordResetEnabled,
TotpEnabled: req.TotpEnabled, InvitationCodeEnabled: req.InvitationCodeEnabled,
SMTPHost: req.SMTPHost, TotpEnabled: req.TotpEnabled,
SMTPPort: req.SMTPPort, SMTPHost: req.SMTPHost,
SMTPUsername: req.SMTPUsername, SMTPPort: req.SMTPPort,
SMTPPassword: req.SMTPPassword, SMTPUsername: req.SMTPUsername,
SMTPFrom: req.SMTPFrom, SMTPPassword: req.SMTPPassword,
SMTPFromName: req.SMTPFromName, SMTPFrom: req.SMTPFrom,
SMTPUseTLS: req.SMTPUseTLS, SMTPFromName: req.SMTPFromName,
TurnstileEnabled: req.TurnstileEnabled, SMTPUseTLS: req.SMTPUseTLS,
TurnstileSiteKey: req.TurnstileSiteKey, TurnstileEnabled: req.TurnstileEnabled,
TurnstileSecretKey: req.TurnstileSecretKey, TurnstileSiteKey: req.TurnstileSiteKey,
LinuxDoConnectEnabled: req.LinuxDoConnectEnabled, TurnstileSecretKey: req.TurnstileSecretKey,
LinuxDoConnectClientID: req.LinuxDoConnectClientID, LinuxDoConnectEnabled: req.LinuxDoConnectEnabled,
LinuxDoConnectClientSecret: req.LinuxDoConnectClientSecret, LinuxDoConnectClientID: req.LinuxDoConnectClientID,
LinuxDoConnectRedirectURL: req.LinuxDoConnectRedirectURL, LinuxDoConnectClientSecret: req.LinuxDoConnectClientSecret,
SiteName: req.SiteName, LinuxDoConnectRedirectURL: req.LinuxDoConnectRedirectURL,
SiteLogo: req.SiteLogo, SiteName: req.SiteName,
SiteSubtitle: req.SiteSubtitle, SiteLogo: req.SiteLogo,
APIBaseURL: req.APIBaseURL, SiteSubtitle: req.SiteSubtitle,
ContactInfo: req.ContactInfo, APIBaseURL: req.APIBaseURL,
DocURL: req.DocURL, ContactInfo: req.ContactInfo,
HomeContent: req.HomeContent, DocURL: req.DocURL,
HideCcsImportButton: req.HideCcsImportButton, HomeContent: req.HomeContent,
PurchaseSubscriptionEnabled: purchaseEnabled, HideCcsImportButton: req.HideCcsImportButton,
PurchaseSubscriptionURL: purchaseURL, PurchaseSubscriptionEnabled: purchaseEnabled,
SoraClientEnabled: req.SoraClientEnabled, PurchaseSubscriptionURL: purchaseURL,
CustomMenuItems: customMenuJSON, SoraClientEnabled: req.SoraClientEnabled,
DefaultConcurrency: req.DefaultConcurrency, CustomMenuItems: customMenuJSON,
DefaultBalance: req.DefaultBalance, DefaultConcurrency: req.DefaultConcurrency,
DefaultSubscriptions: defaultSubscriptions, DefaultBalance: req.DefaultBalance,
EnableModelFallback: req.EnableModelFallback, DefaultSubscriptions: defaultSubscriptions,
FallbackModelAnthropic: req.FallbackModelAnthropic, EnableModelFallback: req.EnableModelFallback,
FallbackModelOpenAI: req.FallbackModelOpenAI, FallbackModelAnthropic: req.FallbackModelAnthropic,
FallbackModelGemini: req.FallbackModelGemini, FallbackModelOpenAI: req.FallbackModelOpenAI,
FallbackModelAntigravity: req.FallbackModelAntigravity, FallbackModelGemini: req.FallbackModelGemini,
EnableIdentityPatch: req.EnableIdentityPatch, FallbackModelAntigravity: req.FallbackModelAntigravity,
IdentityPatchPrompt: req.IdentityPatchPrompt, EnableIdentityPatch: req.EnableIdentityPatch,
MinClaudeCodeVersion: req.MinClaudeCodeVersion, IdentityPatchPrompt: req.IdentityPatchPrompt,
AllowUngroupedKeyScheduling: req.AllowUngroupedKeyScheduling, MinClaudeCodeVersion: req.MinClaudeCodeVersion,
AllowUngroupedKeyScheduling: req.AllowUngroupedKeyScheduling,
OpsMonitoringEnabled: func() bool { OpsMonitoringEnabled: func() bool {
if req.OpsMonitoringEnabled != nil { if req.OpsMonitoringEnabled != nil {
return *req.OpsMonitoringEnabled return *req.OpsMonitoringEnabled
@@ -520,6 +523,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
response.Success(c, dto.SystemSettings{ response.Success(c, dto.SystemSettings{
RegistrationEnabled: updatedSettings.RegistrationEnabled, RegistrationEnabled: updatedSettings.RegistrationEnabled,
EmailVerifyEnabled: updatedSettings.EmailVerifyEnabled, EmailVerifyEnabled: updatedSettings.EmailVerifyEnabled,
RegistrationEmailSuffixWhitelist: updatedSettings.RegistrationEmailSuffixWhitelist,
PromoCodeEnabled: updatedSettings.PromoCodeEnabled, PromoCodeEnabled: updatedSettings.PromoCodeEnabled,
PasswordResetEnabled: updatedSettings.PasswordResetEnabled, PasswordResetEnabled: updatedSettings.PasswordResetEnabled,
InvitationCodeEnabled: updatedSettings.InvitationCodeEnabled, InvitationCodeEnabled: updatedSettings.InvitationCodeEnabled,
@@ -598,6 +602,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.EmailVerifyEnabled != after.EmailVerifyEnabled { if before.EmailVerifyEnabled != after.EmailVerifyEnabled {
changed = append(changed, "email_verify_enabled") changed = append(changed, "email_verify_enabled")
} }
if !equalStringSlice(before.RegistrationEmailSuffixWhitelist, after.RegistrationEmailSuffixWhitelist) {
changed = append(changed, "registration_email_suffix_whitelist")
}
if before.PasswordResetEnabled != after.PasswordResetEnabled { if before.PasswordResetEnabled != after.PasswordResetEnabled {
changed = append(changed, "password_reset_enabled") changed = append(changed, "password_reset_enabled")
} }
@@ -747,6 +754,18 @@ func normalizeDefaultSubscriptions(input []dto.DefaultSubscriptionSetting) []dto
return normalized return normalized
} }
func equalStringSlice(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func equalDefaultSubscriptions(a, b []service.DefaultSubscriptionSetting) bool { func equalDefaultSubscriptions(a, b []service.DefaultSubscriptionSetting) bool {
if len(a) != len(b) { if len(a) != len(b) {
return false return false

View File

@@ -17,13 +17,14 @@ type CustomMenuItem struct {
// SystemSettings represents the admin settings API response payload. // SystemSettings represents the admin settings API response payload.
type SystemSettings struct { type SystemSettings struct {
RegistrationEnabled bool `json:"registration_enabled"` RegistrationEnabled bool `json:"registration_enabled"`
EmailVerifyEnabled bool `json:"email_verify_enabled"` EmailVerifyEnabled bool `json:"email_verify_enabled"`
PromoCodeEnabled bool `json:"promo_code_enabled"` RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
PasswordResetEnabled bool `json:"password_reset_enabled"` PromoCodeEnabled bool `json:"promo_code_enabled"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"` PasswordResetEnabled bool `json:"password_reset_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // TOTP 加密密钥是否已配置 TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // TOTP 加密密钥是否已配置
SMTPHost string `json:"smtp_host"` SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"` SMTPPort int `json:"smtp_port"`
@@ -88,28 +89,29 @@ type DefaultSubscriptionSetting struct {
} }
type PublicSettings struct { type PublicSettings struct {
RegistrationEnabled bool `json:"registration_enabled"` RegistrationEnabled bool `json:"registration_enabled"`
EmailVerifyEnabled bool `json:"email_verify_enabled"` EmailVerifyEnabled bool `json:"email_verify_enabled"`
PromoCodeEnabled bool `json:"promo_code_enabled"` RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
PasswordResetEnabled bool `json:"password_reset_enabled"` PromoCodeEnabled bool `json:"promo_code_enabled"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"` PasswordResetEnabled bool `json:"password_reset_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TurnstileEnabled bool `json:"turnstile_enabled"` TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
TurnstileSiteKey string `json:"turnstile_site_key"` TurnstileEnabled bool `json:"turnstile_enabled"`
SiteName string `json:"site_name"` TurnstileSiteKey string `json:"turnstile_site_key"`
SiteLogo string `json:"site_logo"` SiteName string `json:"site_name"`
SiteSubtitle string `json:"site_subtitle"` SiteLogo string `json:"site_logo"`
APIBaseURL string `json:"api_base_url"` SiteSubtitle string `json:"site_subtitle"`
ContactInfo string `json:"contact_info"` APIBaseURL string `json:"api_base_url"`
DocURL string `json:"doc_url"` ContactInfo string `json:"contact_info"`
HomeContent string `json:"home_content"` DocURL string `json:"doc_url"`
HideCcsImportButton bool `json:"hide_ccs_import_button"` HomeContent string `json:"home_content"`
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` HideCcsImportButton bool `json:"hide_ccs_import_button"`
PurchaseSubscriptionURL string `json:"purchase_subscription_url"` PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"` PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
SoraClientEnabled bool `json:"sora_client_enabled"` LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
Version string `json:"version"` SoraClientEnabled bool `json:"sora_client_enabled"`
Version string `json:"version"`
} }
// SoraS3Settings Sora S3 存储配置 DTO响应用不含敏感字段 // SoraS3Settings Sora S3 存储配置 DTO响应用不含敏感字段

View File

@@ -32,27 +32,28 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
} }
response.Success(c, dto.PublicSettings{ response.Success(c, dto.PublicSettings{
RegistrationEnabled: settings.RegistrationEnabled, RegistrationEnabled: settings.RegistrationEnabled,
EmailVerifyEnabled: settings.EmailVerifyEnabled, EmailVerifyEnabled: settings.EmailVerifyEnabled,
PromoCodeEnabled: settings.PromoCodeEnabled, RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist,
PasswordResetEnabled: settings.PasswordResetEnabled, PromoCodeEnabled: settings.PromoCodeEnabled,
InvitationCodeEnabled: settings.InvitationCodeEnabled, PasswordResetEnabled: settings.PasswordResetEnabled,
TotpEnabled: settings.TotpEnabled, InvitationCodeEnabled: settings.InvitationCodeEnabled,
TurnstileEnabled: settings.TurnstileEnabled, TotpEnabled: settings.TotpEnabled,
TurnstileSiteKey: settings.TurnstileSiteKey, TurnstileEnabled: settings.TurnstileEnabled,
SiteName: settings.SiteName, TurnstileSiteKey: settings.TurnstileSiteKey,
SiteLogo: settings.SiteLogo, SiteName: settings.SiteName,
SiteSubtitle: settings.SiteSubtitle, SiteLogo: settings.SiteLogo,
APIBaseURL: settings.APIBaseURL, SiteSubtitle: settings.SiteSubtitle,
ContactInfo: settings.ContactInfo, APIBaseURL: settings.APIBaseURL,
DocURL: settings.DocURL, ContactInfo: settings.ContactInfo,
HomeContent: settings.HomeContent, DocURL: settings.DocURL,
HideCcsImportButton: settings.HideCcsImportButton, HomeContent: settings.HomeContent,
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, HideCcsImportButton: settings.HideCcsImportButton,
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
CustomMenuItems: dto.ParseUserVisibleMenuItems(settings.CustomMenuItems), PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, CustomMenuItems: dto.ParseUserVisibleMenuItems(settings.CustomMenuItems),
SoraClientEnabled: settings.SoraClientEnabled, LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
Version: h.version, SoraClientEnabled: settings.SoraClientEnabled,
Version: h.version,
}) })
} }

View File

@@ -446,9 +446,10 @@ func TestAPIContracts(t *testing.T) {
setup: func(t *testing.T, deps *contractDeps) { setup: func(t *testing.T, deps *contractDeps) {
t.Helper() t.Helper()
deps.settingRepo.SetAll(map[string]string{ deps.settingRepo.SetAll(map[string]string{
service.SettingKeyRegistrationEnabled: "true", service.SettingKeyRegistrationEnabled: "true",
service.SettingKeyEmailVerifyEnabled: "false", service.SettingKeyEmailVerifyEnabled: "false",
service.SettingKeyPromoCodeEnabled: "true", service.SettingKeyRegistrationEmailSuffixWhitelist: "[]",
service.SettingKeyPromoCodeEnabled: "true",
service.SettingKeySMTPHost: "smtp.example.com", service.SettingKeySMTPHost: "smtp.example.com",
service.SettingKeySMTPPort: "587", service.SettingKeySMTPPort: "587",
@@ -487,6 +488,7 @@ func TestAPIContracts(t *testing.T) {
"data": { "data": {
"registration_enabled": true, "registration_enabled": true,
"email_verify_enabled": false, "email_verify_enabled": false,
"registration_email_suffix_whitelist": [],
"promo_code_enabled": true, "promo_code_enabled": true,
"password_reset_enabled": false, "password_reset_enabled": false,
"totp_enabled": false, "totp_enabled": false,

View File

@@ -8,6 +8,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/mail" "net/mail"
"strconv"
"strings" "strings"
"time" "time"
@@ -33,6 +34,7 @@ var (
ErrRefreshTokenExpired = infraerrors.Unauthorized("REFRESH_TOKEN_EXPIRED", "refresh token has expired") ErrRefreshTokenExpired = infraerrors.Unauthorized("REFRESH_TOKEN_EXPIRED", "refresh token has expired")
ErrRefreshTokenReused = infraerrors.Unauthorized("REFRESH_TOKEN_REUSED", "refresh token has been reused") ErrRefreshTokenReused = infraerrors.Unauthorized("REFRESH_TOKEN_REUSED", "refresh token has been reused")
ErrEmailVerifyRequired = infraerrors.BadRequest("EMAIL_VERIFY_REQUIRED", "email verification is required") ErrEmailVerifyRequired = infraerrors.BadRequest("EMAIL_VERIFY_REQUIRED", "email verification is required")
ErrEmailSuffixNotAllowed = infraerrors.BadRequest("EMAIL_SUFFIX_NOT_ALLOWED", "email suffix is not allowed")
ErrRegDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled") ErrRegDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled")
ErrServiceUnavailable = infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable") ErrServiceUnavailable = infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable")
ErrInvitationCodeRequired = infraerrors.BadRequest("INVITATION_CODE_REQUIRED", "invitation code is required") ErrInvitationCodeRequired = infraerrors.BadRequest("INVITATION_CODE_REQUIRED", "invitation code is required")
@@ -115,6 +117,9 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
if isReservedEmail(email) { if isReservedEmail(email) {
return "", nil, ErrEmailReserved return "", nil, ErrEmailReserved
} }
if err := s.validateRegistrationEmailPolicy(ctx, email); err != nil {
return "", nil, err
}
// 检查是否需要邀请码 // 检查是否需要邀请码
var invitationRedeemCode *RedeemCode var invitationRedeemCode *RedeemCode
@@ -241,6 +246,9 @@ func (s *AuthService) SendVerifyCode(ctx context.Context, email string) error {
if isReservedEmail(email) { if isReservedEmail(email) {
return ErrEmailReserved return ErrEmailReserved
} }
if err := s.validateRegistrationEmailPolicy(ctx, email); err != nil {
return err
}
// 检查邮箱是否已存在 // 检查邮箱是否已存在
existsEmail, err := s.userRepo.ExistsByEmail(ctx, email) existsEmail, err := s.userRepo.ExistsByEmail(ctx, email)
@@ -279,6 +287,9 @@ func (s *AuthService) SendVerifyCodeAsync(ctx context.Context, email string) (*S
if isReservedEmail(email) { if isReservedEmail(email) {
return nil, ErrEmailReserved return nil, ErrEmailReserved
} }
if err := s.validateRegistrationEmailPolicy(ctx, email); err != nil {
return nil, err
}
// 检查邮箱是否已存在 // 检查邮箱是否已存在
existsEmail, err := s.userRepo.ExistsByEmail(ctx, email) existsEmail, err := s.userRepo.ExistsByEmail(ctx, email)
@@ -624,6 +635,32 @@ func (s *AuthService) assignDefaultSubscriptions(ctx context.Context, userID int
} }
} }
func (s *AuthService) validateRegistrationEmailPolicy(ctx context.Context, email string) error {
if s.settingService == nil {
return nil
}
whitelist := s.settingService.GetRegistrationEmailSuffixWhitelist(ctx)
if !IsRegistrationEmailSuffixAllowed(email, whitelist) {
return buildEmailSuffixNotAllowedError(whitelist)
}
return nil
}
func buildEmailSuffixNotAllowedError(whitelist []string) error {
if len(whitelist) == 0 {
return ErrEmailSuffixNotAllowed
}
allowed := strings.Join(whitelist, ", ")
return infraerrors.BadRequest(
"EMAIL_SUFFIX_NOT_ALLOWED",
fmt.Sprintf("email suffix is not allowed, allowed suffixes: %s", allowed),
).WithMetadata(map[string]string{
"allowed_suffixes": strings.Join(whitelist, ","),
"allowed_suffix_count": strconv.Itoa(len(whitelist)),
})
}
// ValidateToken 验证JWT token并返回用户声明 // ValidateToken 验证JWT token并返回用户声明
func (s *AuthService) ValidateToken(tokenString string) (*JWTClaims, error) { func (s *AuthService) ValidateToken(tokenString string) (*JWTClaims, error) {
// 先做长度校验,尽早拒绝异常超长 token降低 DoS 风险。 // 先做长度校验,尽早拒绝异常超长 token降低 DoS 风险。

View File

@@ -9,6 +9,7 @@ import (
"time" "time"
"github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -231,6 +232,51 @@ func TestAuthService_Register_ReservedEmail(t *testing.T) {
require.ErrorIs(t, err, ErrEmailReserved) require.ErrorIs(t, err, ErrEmailReserved)
} }
func TestAuthService_Register_EmailSuffixNotAllowed(t *testing.T) {
repo := &userRepoStub{}
service := newAuthService(repo, map[string]string{
SettingKeyRegistrationEnabled: "true",
SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com","@company.com"]`,
}, nil)
_, _, err := service.Register(context.Background(), "user@other.com", "password")
require.ErrorIs(t, err, ErrEmailSuffixNotAllowed)
appErr := infraerrors.FromError(err)
require.Contains(t, appErr.Message, "@example.com")
require.Contains(t, appErr.Message, "@company.com")
require.Equal(t, "EMAIL_SUFFIX_NOT_ALLOWED", appErr.Reason)
require.Equal(t, "2", appErr.Metadata["allowed_suffix_count"])
require.Equal(t, "@example.com,@company.com", appErr.Metadata["allowed_suffixes"])
}
func TestAuthService_Register_EmailSuffixAllowed(t *testing.T) {
repo := &userRepoStub{nextID: 8}
service := newAuthService(repo, map[string]string{
SettingKeyRegistrationEnabled: "true",
SettingKeyRegistrationEmailSuffixWhitelist: `["example.com"]`,
}, nil)
_, user, err := service.Register(context.Background(), "user@example.com", "password")
require.NoError(t, err)
require.NotNil(t, user)
require.Equal(t, int64(8), user.ID)
}
func TestAuthService_SendVerifyCode_EmailSuffixNotAllowed(t *testing.T) {
repo := &userRepoStub{}
service := newAuthService(repo, map[string]string{
SettingKeyRegistrationEnabled: "true",
SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com","@company.com"]`,
}, nil)
err := service.SendVerifyCode(context.Background(), "user@other.com")
require.ErrorIs(t, err, ErrEmailSuffixNotAllowed)
appErr := infraerrors.FromError(err)
require.Contains(t, appErr.Message, "@example.com")
require.Contains(t, appErr.Message, "@company.com")
require.Equal(t, "2", appErr.Metadata["allowed_suffix_count"])
}
func TestAuthService_Register_CreateError(t *testing.T) { func TestAuthService_Register_CreateError(t *testing.T) {
repo := &userRepoStub{createErr: errors.New("create failed")} repo := &userRepoStub{createErr: errors.New("create failed")}
service := newAuthService(repo, map[string]string{ service := newAuthService(repo, map[string]string{
@@ -402,7 +448,7 @@ func TestAuthService_Register_AssignsDefaultSubscriptions(t *testing.T) {
repo := &userRepoStub{nextID: 42} repo := &userRepoStub{nextID: 42}
assigner := &defaultSubscriptionAssignerStub{} assigner := &defaultSubscriptionAssignerStub{}
service := newAuthService(repo, map[string]string{ service := newAuthService(repo, map[string]string{
SettingKeyRegistrationEnabled: "true", SettingKeyRegistrationEnabled: "true",
SettingKeyDefaultSubscriptions: `[{"group_id":11,"validity_days":30},{"group_id":12,"validity_days":7}]`, SettingKeyDefaultSubscriptions: `[{"group_id":11,"validity_days":30},{"group_id":12,"validity_days":7}]`,
}, nil) }, nil)
service.defaultSubAssigner = assigner service.defaultSubAssigner = assigner

View File

@@ -74,11 +74,12 @@ const LinuxDoConnectSyntheticEmailDomain = "@linuxdo-connect.invalid"
// Setting keys // Setting keys
const ( const (
// 注册设置 // 注册设置
SettingKeyRegistrationEnabled = "registration_enabled" // 是否开放注册 SettingKeyRegistrationEnabled = "registration_enabled" // 是否开放注册
SettingKeyEmailVerifyEnabled = "email_verify_enabled" // 是否开启邮件验证 SettingKeyEmailVerifyEnabled = "email_verify_enabled" // 是否开启邮件验证
SettingKeyPromoCodeEnabled = "promo_code_enabled" // 是否启用优惠码功能 SettingKeyRegistrationEmailSuffixWhitelist = "registration_email_suffix_whitelist" // 注册邮箱后缀白名单JSON 数组)
SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证) SettingKeyPromoCodeEnabled = "promo_code_enabled" // 是否启用优惠码功能
SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册 SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证)
SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册
// 邮件服务设置 // 邮件服务设置
SettingKeySMTPHost = "smtp_host" // SMTP服务器地址 SettingKeySMTPHost = "smtp_host" // SMTP服务器地址

View File

@@ -0,0 +1,123 @@
package service
import (
"encoding/json"
"fmt"
"regexp"
"strings"
)
var registrationEmailDomainPattern = regexp.MustCompile(
`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$`,
)
// RegistrationEmailSuffix extracts normalized suffix in "@domain" form.
func RegistrationEmailSuffix(email string) string {
_, domain, ok := splitEmailForPolicy(email)
if !ok {
return ""
}
return "@" + domain
}
// IsRegistrationEmailSuffixAllowed checks whether an email is allowed by suffix whitelist.
// Empty whitelist means allow all.
func IsRegistrationEmailSuffixAllowed(email string, whitelist []string) bool {
if len(whitelist) == 0 {
return true
}
suffix := RegistrationEmailSuffix(email)
if suffix == "" {
return false
}
for _, allowed := range whitelist {
if suffix == allowed {
return true
}
}
return false
}
// NormalizeRegistrationEmailSuffixWhitelist normalizes and validates suffix whitelist items.
func NormalizeRegistrationEmailSuffixWhitelist(raw []string) ([]string, error) {
return normalizeRegistrationEmailSuffixWhitelist(raw, true)
}
// ParseRegistrationEmailSuffixWhitelist parses persisted JSON into normalized suffixes.
// Invalid entries are ignored to keep old misconfigurations from breaking runtime reads.
func ParseRegistrationEmailSuffixWhitelist(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return []string{}
}
var items []string
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return []string{}
}
normalized, _ := normalizeRegistrationEmailSuffixWhitelist(items, false)
if len(normalized) == 0 {
return []string{}
}
return normalized
}
func normalizeRegistrationEmailSuffixWhitelist(raw []string, strict bool) ([]string, error) {
if len(raw) == 0 {
return nil, nil
}
seen := make(map[string]struct{}, len(raw))
out := make([]string, 0, len(raw))
for _, item := range raw {
normalized, err := normalizeRegistrationEmailSuffix(item)
if err != nil {
if strict {
return nil, err
}
continue
}
if normalized == "" {
continue
}
if _, ok := seen[normalized]; ok {
continue
}
seen[normalized] = struct{}{}
out = append(out, normalized)
}
if len(out) == 0 {
return nil, nil
}
return out, nil
}
func normalizeRegistrationEmailSuffix(raw string) (string, error) {
value := strings.ToLower(strings.TrimSpace(raw))
if value == "" {
return "", nil
}
domain := value
if strings.Contains(value, "@") {
if !strings.HasPrefix(value, "@") || strings.Count(value, "@") != 1 {
return "", fmt.Errorf("invalid email suffix: %q", raw)
}
domain = strings.TrimPrefix(value, "@")
}
if domain == "" || strings.Contains(domain, "@") || !registrationEmailDomainPattern.MatchString(domain) {
return "", fmt.Errorf("invalid email suffix: %q", raw)
}
return "@" + domain, nil
}
func splitEmailForPolicy(raw string) (local string, domain string, ok bool) {
email := strings.ToLower(strings.TrimSpace(raw))
local, domain, found := strings.Cut(email, "@")
if !found || local == "" || domain == "" || strings.Contains(domain, "@") {
return "", "", false
}
return local, domain, true
}

View File

@@ -0,0 +1,31 @@
//go:build unit
package service
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestNormalizeRegistrationEmailSuffixWhitelist(t *testing.T) {
got, err := NormalizeRegistrationEmailSuffixWhitelist([]string{"example.com", "@EXAMPLE.COM", " @foo.bar "})
require.NoError(t, err)
require.Equal(t, []string{"@example.com", "@foo.bar"}, got)
}
func TestNormalizeRegistrationEmailSuffixWhitelist_Invalid(t *testing.T) {
_, err := NormalizeRegistrationEmailSuffixWhitelist([]string{"@invalid_domain"})
require.Error(t, err)
}
func TestParseRegistrationEmailSuffixWhitelist(t *testing.T) {
got := ParseRegistrationEmailSuffixWhitelist(`["example.com","@foo.bar","@invalid_domain"]`)
require.Equal(t, []string{"@example.com", "@foo.bar"}, got)
}
func TestIsRegistrationEmailSuffixAllowed(t *testing.T) {
require.True(t, IsRegistrationEmailSuffixAllowed("user@example.com", []string{"@example.com"}))
require.False(t, IsRegistrationEmailSuffixAllowed("user@sub.example.com", []string{"@example.com"}))
require.True(t, IsRegistrationEmailSuffixAllowed("user@any.com", []string{}))
}

View File

@@ -108,6 +108,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
keys := []string{ keys := []string{
SettingKeyRegistrationEnabled, SettingKeyRegistrationEnabled,
SettingKeyEmailVerifyEnabled, SettingKeyEmailVerifyEnabled,
SettingKeyRegistrationEmailSuffixWhitelist,
SettingKeyPromoCodeEnabled, SettingKeyPromoCodeEnabled,
SettingKeyPasswordResetEnabled, SettingKeyPasswordResetEnabled,
SettingKeyInvitationCodeEnabled, SettingKeyInvitationCodeEnabled,
@@ -144,29 +145,33 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
// Password reset requires email verification to be enabled // Password reset requires email verification to be enabled
emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true" emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true"
passwordResetEnabled := emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true" passwordResetEnabled := emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true"
registrationEmailSuffixWhitelist := ParseRegistrationEmailSuffixWhitelist(
settings[SettingKeyRegistrationEmailSuffixWhitelist],
)
return &PublicSettings{ return &PublicSettings{
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
EmailVerifyEnabled: emailVerifyEnabled, EmailVerifyEnabled: emailVerifyEnabled,
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 RegistrationEmailSuffixWhitelist: registrationEmailSuffixWhitelist,
PasswordResetEnabled: passwordResetEnabled, PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", PasswordResetEnabled: passwordResetEnabled,
TotpEnabled: settings[SettingKeyTotpEnabled] == "true", InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true", TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey], TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true",
SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"), TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
SiteLogo: settings[SettingKeySiteLogo], SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"),
SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"), SiteLogo: settings[SettingKeySiteLogo],
APIBaseURL: settings[SettingKeyAPIBaseURL], SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"),
ContactInfo: settings[SettingKeyContactInfo], APIBaseURL: settings[SettingKeyAPIBaseURL],
DocURL: settings[SettingKeyDocURL], ContactInfo: settings[SettingKeyContactInfo],
HomeContent: settings[SettingKeyHomeContent], DocURL: settings[SettingKeyDocURL],
HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true", HomeContent: settings[SettingKeyHomeContent],
PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true", HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true",
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]), PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true", PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
CustomMenuItems: settings[SettingKeyCustomMenuItems], SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true",
LinuxDoOAuthEnabled: linuxDoEnabled, CustomMenuItems: settings[SettingKeyCustomMenuItems],
LinuxDoOAuthEnabled: linuxDoEnabled,
}, nil }, nil
} }
@@ -196,51 +201,53 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
// Return a struct that matches the frontend's expected format // Return a struct that matches the frontend's expected format
return &struct { return &struct {
RegistrationEnabled bool `json:"registration_enabled"` RegistrationEnabled bool `json:"registration_enabled"`
EmailVerifyEnabled bool `json:"email_verify_enabled"` EmailVerifyEnabled bool `json:"email_verify_enabled"`
PromoCodeEnabled bool `json:"promo_code_enabled"` RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
PasswordResetEnabled bool `json:"password_reset_enabled"` PromoCodeEnabled bool `json:"promo_code_enabled"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"` PasswordResetEnabled bool `json:"password_reset_enabled"`
TotpEnabled bool `json:"totp_enabled"` InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TurnstileEnabled bool `json:"turnstile_enabled"` TotpEnabled bool `json:"totp_enabled"`
TurnstileSiteKey string `json:"turnstile_site_key,omitempty"` TurnstileEnabled bool `json:"turnstile_enabled"`
SiteName string `json:"site_name"` TurnstileSiteKey string `json:"turnstile_site_key,omitempty"`
SiteLogo string `json:"site_logo,omitempty"` SiteName string `json:"site_name"`
SiteSubtitle string `json:"site_subtitle,omitempty"` SiteLogo string `json:"site_logo,omitempty"`
APIBaseURL string `json:"api_base_url,omitempty"` SiteSubtitle string `json:"site_subtitle,omitempty"`
ContactInfo string `json:"contact_info,omitempty"` APIBaseURL string `json:"api_base_url,omitempty"`
DocURL string `json:"doc_url,omitempty"` ContactInfo string `json:"contact_info,omitempty"`
HomeContent string `json:"home_content,omitempty"` DocURL string `json:"doc_url,omitempty"`
HideCcsImportButton bool `json:"hide_ccs_import_button"` HomeContent string `json:"home_content,omitempty"`
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` HideCcsImportButton bool `json:"hide_ccs_import_button"`
PurchaseSubscriptionURL string `json:"purchase_subscription_url,omitempty"` PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
SoraClientEnabled bool `json:"sora_client_enabled"` PurchaseSubscriptionURL string `json:"purchase_subscription_url,omitempty"`
CustomMenuItems json.RawMessage `json:"custom_menu_items"` SoraClientEnabled bool `json:"sora_client_enabled"`
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` CustomMenuItems json.RawMessage `json:"custom_menu_items"`
Version string `json:"version,omitempty"` LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
Version string `json:"version,omitempty"`
}{ }{
RegistrationEnabled: settings.RegistrationEnabled, RegistrationEnabled: settings.RegistrationEnabled,
EmailVerifyEnabled: settings.EmailVerifyEnabled, EmailVerifyEnabled: settings.EmailVerifyEnabled,
PromoCodeEnabled: settings.PromoCodeEnabled, RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist,
PasswordResetEnabled: settings.PasswordResetEnabled, PromoCodeEnabled: settings.PromoCodeEnabled,
InvitationCodeEnabled: settings.InvitationCodeEnabled, PasswordResetEnabled: settings.PasswordResetEnabled,
TotpEnabled: settings.TotpEnabled, InvitationCodeEnabled: settings.InvitationCodeEnabled,
TurnstileEnabled: settings.TurnstileEnabled, TotpEnabled: settings.TotpEnabled,
TurnstileSiteKey: settings.TurnstileSiteKey, TurnstileEnabled: settings.TurnstileEnabled,
SiteName: settings.SiteName, TurnstileSiteKey: settings.TurnstileSiteKey,
SiteLogo: settings.SiteLogo, SiteName: settings.SiteName,
SiteSubtitle: settings.SiteSubtitle, SiteLogo: settings.SiteLogo,
APIBaseURL: settings.APIBaseURL, SiteSubtitle: settings.SiteSubtitle,
ContactInfo: settings.ContactInfo, APIBaseURL: settings.APIBaseURL,
DocURL: settings.DocURL, ContactInfo: settings.ContactInfo,
HomeContent: settings.HomeContent, DocURL: settings.DocURL,
HideCcsImportButton: settings.HideCcsImportButton, HomeContent: settings.HomeContent,
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, HideCcsImportButton: settings.HideCcsImportButton,
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
SoraClientEnabled: settings.SoraClientEnabled, PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems), SoraClientEnabled: settings.SoraClientEnabled,
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems),
Version: s.version, LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
Version: s.version,
}, nil }, nil
} }
@@ -356,12 +363,25 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
if err := s.validateDefaultSubscriptionGroups(ctx, settings.DefaultSubscriptions); err != nil { if err := s.validateDefaultSubscriptionGroups(ctx, settings.DefaultSubscriptions); err != nil {
return err return err
} }
normalizedWhitelist, err := NormalizeRegistrationEmailSuffixWhitelist(settings.RegistrationEmailSuffixWhitelist)
if err != nil {
return infraerrors.BadRequest("INVALID_REGISTRATION_EMAIL_SUFFIX_WHITELIST", err.Error())
}
if normalizedWhitelist == nil {
normalizedWhitelist = []string{}
}
settings.RegistrationEmailSuffixWhitelist = normalizedWhitelist
updates := make(map[string]string) updates := make(map[string]string)
// 注册设置 // 注册设置
updates[SettingKeyRegistrationEnabled] = strconv.FormatBool(settings.RegistrationEnabled) updates[SettingKeyRegistrationEnabled] = strconv.FormatBool(settings.RegistrationEnabled)
updates[SettingKeyEmailVerifyEnabled] = strconv.FormatBool(settings.EmailVerifyEnabled) updates[SettingKeyEmailVerifyEnabled] = strconv.FormatBool(settings.EmailVerifyEnabled)
registrationEmailSuffixWhitelistJSON, err := json.Marshal(settings.RegistrationEmailSuffixWhitelist)
if err != nil {
return fmt.Errorf("marshal registration email suffix whitelist: %w", err)
}
updates[SettingKeyRegistrationEmailSuffixWhitelist] = string(registrationEmailSuffixWhitelistJSON)
updates[SettingKeyPromoCodeEnabled] = strconv.FormatBool(settings.PromoCodeEnabled) updates[SettingKeyPromoCodeEnabled] = strconv.FormatBool(settings.PromoCodeEnabled)
updates[SettingKeyPasswordResetEnabled] = strconv.FormatBool(settings.PasswordResetEnabled) updates[SettingKeyPasswordResetEnabled] = strconv.FormatBool(settings.PasswordResetEnabled)
updates[SettingKeyInvitationCodeEnabled] = strconv.FormatBool(settings.InvitationCodeEnabled) updates[SettingKeyInvitationCodeEnabled] = strconv.FormatBool(settings.InvitationCodeEnabled)
@@ -514,6 +534,15 @@ func (s *SettingService) IsEmailVerifyEnabled(ctx context.Context) bool {
return value == "true" 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)
if err != nil {
return []string{}
}
return ParseRegistrationEmailSuffixWhitelist(value)
}
// IsPromoCodeEnabled 检查是否启用优惠码功能 // IsPromoCodeEnabled 检查是否启用优惠码功能
func (s *SettingService) IsPromoCodeEnabled(ctx context.Context) bool { func (s *SettingService) IsPromoCodeEnabled(ctx context.Context) bool {
value, err := s.settingRepo.GetValue(ctx, SettingKeyPromoCodeEnabled) value, err := s.settingRepo.GetValue(ctx, SettingKeyPromoCodeEnabled)
@@ -617,20 +646,21 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
// 初始化默认设置 // 初始化默认设置
defaults := map[string]string{ defaults := map[string]string{
SettingKeyRegistrationEnabled: "true", SettingKeyRegistrationEnabled: "true",
SettingKeyEmailVerifyEnabled: "false", SettingKeyEmailVerifyEnabled: "false",
SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能 SettingKeyRegistrationEmailSuffixWhitelist: "[]",
SettingKeySiteName: "Sub2API", SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能
SettingKeySiteLogo: "", SettingKeySiteName: "Sub2API",
SettingKeyPurchaseSubscriptionEnabled: "false", SettingKeySiteLogo: "",
SettingKeyPurchaseSubscriptionURL: "", SettingKeyPurchaseSubscriptionEnabled: "false",
SettingKeySoraClientEnabled: "false", SettingKeyPurchaseSubscriptionURL: "",
SettingKeyCustomMenuItems: "[]", SettingKeySoraClientEnabled: "false",
SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency), SettingKeyCustomMenuItems: "[]",
SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64), SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency),
SettingKeyDefaultSubscriptions: "[]", SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64),
SettingKeySMTPPort: "587", SettingKeyDefaultSubscriptions: "[]",
SettingKeySMTPUseTLS: "false", SettingKeySMTPPort: "587",
SettingKeySMTPUseTLS: "false",
// Model fallback defaults // Model fallback defaults
SettingKeyEnableModelFallback: "false", SettingKeyEnableModelFallback: "false",
SettingKeyFallbackModelAnthropic: "claude-3-5-sonnet-20241022", SettingKeyFallbackModelAnthropic: "claude-3-5-sonnet-20241022",
@@ -661,33 +691,34 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
func (s *SettingService) parseSettings(settings map[string]string) *SystemSettings { func (s *SettingService) parseSettings(settings map[string]string) *SystemSettings {
emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true" emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true"
result := &SystemSettings{ result := &SystemSettings{
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
EmailVerifyEnabled: emailVerifyEnabled, EmailVerifyEnabled: emailVerifyEnabled,
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 RegistrationEmailSuffixWhitelist: ParseRegistrationEmailSuffixWhitelist(settings[SettingKeyRegistrationEmailSuffixWhitelist]),
PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true", PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true",
TotpEnabled: settings[SettingKeyTotpEnabled] == "true", InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
SMTPHost: settings[SettingKeySMTPHost], TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
SMTPUsername: settings[SettingKeySMTPUsername], SMTPHost: settings[SettingKeySMTPHost],
SMTPFrom: settings[SettingKeySMTPFrom], SMTPUsername: settings[SettingKeySMTPUsername],
SMTPFromName: settings[SettingKeySMTPFromName], SMTPFrom: settings[SettingKeySMTPFrom],
SMTPUseTLS: settings[SettingKeySMTPUseTLS] == "true", SMTPFromName: settings[SettingKeySMTPFromName],
SMTPPasswordConfigured: settings[SettingKeySMTPPassword] != "", SMTPUseTLS: settings[SettingKeySMTPUseTLS] == "true",
TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true", SMTPPasswordConfigured: settings[SettingKeySMTPPassword] != "",
TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey], TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true",
TurnstileSecretKeyConfigured: settings[SettingKeyTurnstileSecretKey] != "", TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"), TurnstileSecretKeyConfigured: settings[SettingKeyTurnstileSecretKey] != "",
SiteLogo: settings[SettingKeySiteLogo], SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"),
SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"), SiteLogo: settings[SettingKeySiteLogo],
APIBaseURL: settings[SettingKeyAPIBaseURL], SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"),
ContactInfo: settings[SettingKeyContactInfo], APIBaseURL: settings[SettingKeyAPIBaseURL],
DocURL: settings[SettingKeyDocURL], ContactInfo: settings[SettingKeyContactInfo],
HomeContent: settings[SettingKeyHomeContent], DocURL: settings[SettingKeyDocURL],
HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true", HomeContent: settings[SettingKeyHomeContent],
PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true", HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true",
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]), PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true", PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
CustomMenuItems: settings[SettingKeyCustomMenuItems], SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true",
CustomMenuItems: settings[SettingKeyCustomMenuItems],
} }
// 解析整数类型 // 解析整数类型

View File

@@ -0,0 +1,64 @@
//go:build unit
package service
import (
"context"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
)
type settingPublicRepoStub struct {
values map[string]string
}
func (s *settingPublicRepoStub) Get(ctx context.Context, key string) (*Setting, error) {
panic("unexpected Get call")
}
func (s *settingPublicRepoStub) GetValue(ctx context.Context, key string) (string, error) {
panic("unexpected GetValue call")
}
func (s *settingPublicRepoStub) Set(ctx context.Context, key, value string) error {
panic("unexpected Set call")
}
func (s *settingPublicRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) {
out := make(map[string]string, len(keys))
for _, key := range keys {
if value, ok := s.values[key]; ok {
out[key] = value
}
}
return out, nil
}
func (s *settingPublicRepoStub) SetMultiple(ctx context.Context, settings map[string]string) error {
panic("unexpected SetMultiple call")
}
func (s *settingPublicRepoStub) GetAll(ctx context.Context) (map[string]string, error) {
panic("unexpected GetAll call")
}
func (s *settingPublicRepoStub) Delete(ctx context.Context, key string) error {
panic("unexpected Delete call")
}
func TestSettingService_GetPublicSettings_ExposesRegistrationEmailSuffixWhitelist(t *testing.T) {
repo := &settingPublicRepoStub{
values: map[string]string{
SettingKeyRegistrationEnabled: "true",
SettingKeyEmailVerifyEnabled: "true",
SettingKeyRegistrationEmailSuffixWhitelist: `["@EXAMPLE.com"," @foo.bar ","@invalid_domain",""]`,
},
}
svc := NewSettingService(repo, &config.Config{})
settings, err := svc.GetPublicSettings(context.Background())
require.NoError(t, err)
require.Equal(t, []string{"@example.com", "@foo.bar"}, settings.RegistrationEmailSuffixWhitelist)
}

View File

@@ -172,6 +172,28 @@ func TestSettingService_UpdateSettings_DefaultSubscriptions_RejectsDuplicateGrou
require.Nil(t, repo.updates) require.Nil(t, repo.updates)
} }
func TestSettingService_UpdateSettings_RegistrationEmailSuffixWhitelist_Normalized(t *testing.T) {
repo := &settingUpdateRepoStub{}
svc := NewSettingService(repo, &config.Config{})
err := svc.UpdateSettings(context.Background(), &SystemSettings{
RegistrationEmailSuffixWhitelist: []string{"example.com", "@EXAMPLE.com", " @foo.bar "},
})
require.NoError(t, err)
require.Equal(t, `["@example.com","@foo.bar"]`, repo.updates[SettingKeyRegistrationEmailSuffixWhitelist])
}
func TestSettingService_UpdateSettings_RegistrationEmailSuffixWhitelist_Invalid(t *testing.T) {
repo := &settingUpdateRepoStub{}
svc := NewSettingService(repo, &config.Config{})
err := svc.UpdateSettings(context.Background(), &SystemSettings{
RegistrationEmailSuffixWhitelist: []string{"@invalid_domain"},
})
require.Error(t, err)
require.Equal(t, "INVALID_REGISTRATION_EMAIL_SUFFIX_WHITELIST", infraerrors.Reason(err))
}
func TestParseDefaultSubscriptions_NormalizesValues(t *testing.T) { func TestParseDefaultSubscriptions_NormalizesValues(t *testing.T) {
got := parseDefaultSubscriptions(`[{"group_id":11,"validity_days":30},{"group_id":11,"validity_days":60},{"group_id":0,"validity_days":10},{"group_id":12,"validity_days":99999}]`) got := parseDefaultSubscriptions(`[{"group_id":11,"validity_days":30},{"group_id":11,"validity_days":60},{"group_id":0,"validity_days":10},{"group_id":12,"validity_days":99999}]`)
require.Equal(t, []DefaultSubscriptionSetting{ require.Equal(t, []DefaultSubscriptionSetting{

View File

@@ -1,12 +1,13 @@
package service package service
type SystemSettings struct { type SystemSettings struct {
RegistrationEnabled bool RegistrationEnabled bool
EmailVerifyEnabled bool EmailVerifyEnabled bool
PromoCodeEnabled bool RegistrationEmailSuffixWhitelist []string
PasswordResetEnabled bool PromoCodeEnabled bool
InvitationCodeEnabled bool PasswordResetEnabled bool
TotpEnabled bool // TOTP 双因素认证 InvitationCodeEnabled bool
TotpEnabled bool // TOTP 双因素认证
SMTPHost string SMTPHost string
SMTPPort int SMTPPort int
@@ -76,22 +77,23 @@ type DefaultSubscriptionSetting struct {
} }
type PublicSettings struct { type PublicSettings struct {
RegistrationEnabled bool RegistrationEnabled bool
EmailVerifyEnabled bool EmailVerifyEnabled bool
PromoCodeEnabled bool RegistrationEmailSuffixWhitelist []string
PasswordResetEnabled bool PromoCodeEnabled bool
InvitationCodeEnabled bool PasswordResetEnabled bool
TotpEnabled bool // TOTP 双因素认证 InvitationCodeEnabled bool
TurnstileEnabled bool TotpEnabled bool // TOTP 双因素认证
TurnstileSiteKey string TurnstileEnabled bool
SiteName string TurnstileSiteKey string
SiteLogo string SiteName string
SiteSubtitle string SiteLogo string
APIBaseURL string SiteSubtitle string
ContactInfo string APIBaseURL string
DocURL string ContactInfo string
HomeContent string DocURL string
HideCcsImportButton bool HomeContent string
HideCcsImportButton bool
PurchaseSubscriptionEnabled bool PurchaseSubscriptionEnabled bool
PurchaseSubscriptionURL string PurchaseSubscriptionURL string

View File

@@ -18,6 +18,7 @@ export interface SystemSettings {
// Registration settings // Registration settings
registration_enabled: boolean registration_enabled: boolean
email_verify_enabled: boolean email_verify_enabled: boolean
registration_email_suffix_whitelist: string[]
promo_code_enabled: boolean promo_code_enabled: boolean
password_reset_enabled: boolean password_reset_enabled: boolean
invitation_code_enabled: boolean invitation_code_enabled: boolean
@@ -86,6 +87,7 @@ export interface SystemSettings {
export interface UpdateSettingsRequest { export interface UpdateSettingsRequest {
registration_enabled?: boolean registration_enabled?: boolean
email_verify_enabled?: boolean email_verify_enabled?: boolean
registration_email_suffix_whitelist?: string[]
promo_code_enabled?: boolean promo_code_enabled?: boolean
password_reset_enabled?: boolean password_reset_enabled?: boolean
invitation_code_enabled?: boolean invitation_code_enabled?: boolean

View File

@@ -312,6 +312,9 @@ export default {
passwordMinLength: 'Password must be at least 6 characters', passwordMinLength: 'Password must be at least 6 characters',
loginFailed: 'Login failed. Please check your credentials and try again.', loginFailed: 'Login failed. Please check your credentials and try again.',
registrationFailed: 'Registration failed. Please try again.', registrationFailed: 'Registration failed. Please try again.',
emailSuffixNotAllowed: 'This email domain is not allowed for registration.',
emailSuffixNotAllowedWithAllowed:
'This email domain is not allowed. Allowed domains: {suffixes}',
loginSuccess: 'Login successful! Welcome back.', loginSuccess: 'Login successful! Welcome back.',
accountCreatedSuccess: 'Account created successfully! Welcome to {siteName}.', accountCreatedSuccess: 'Account created successfully! Welcome to {siteName}.',
reloginRequired: 'Session expired. Please log in again.', reloginRequired: 'Session expired. Please log in again.',
@@ -3528,6 +3531,11 @@ export default {
enableRegistrationHint: 'Allow new users to register', enableRegistrationHint: 'Allow new users to register',
emailVerification: 'Email Verification', emailVerification: 'Email Verification',
emailVerificationHint: 'Require email verification for new registrations', emailVerificationHint: 'Require email verification for new registrations',
emailSuffixWhitelist: 'Email Domain Whitelist',
emailSuffixWhitelistHint:
'Only email addresses from the specified domains can register (for example, @qq.com, @gmail.com)',
emailSuffixWhitelistPlaceholder: 'example.com',
emailSuffixWhitelistInputHint: 'Leave empty for no restriction',
promoCode: 'Promo Code', promoCode: 'Promo Code',
promoCodeHint: 'Allow users to use promo codes during registration', promoCodeHint: 'Allow users to use promo codes during registration',
invitationCode: 'Invitation Code Registration', invitationCode: 'Invitation Code Registration',

View File

@@ -312,6 +312,8 @@ export default {
passwordMinLength: '密码至少需要 6 个字符', passwordMinLength: '密码至少需要 6 个字符',
loginFailed: '登录失败,请检查您的凭据后重试。', loginFailed: '登录失败,请检查您的凭据后重试。',
registrationFailed: '注册失败,请重试。', registrationFailed: '注册失败,请重试。',
emailSuffixNotAllowed: '该邮箱域名不在允许注册范围内。',
emailSuffixNotAllowedWithAllowed: '该邮箱域名不被允许。可用域名:{suffixes}',
loginSuccess: '登录成功!欢迎回来。', loginSuccess: '登录成功!欢迎回来。',
accountCreatedSuccess: '账户创建成功!欢迎使用 {siteName}。', accountCreatedSuccess: '账户创建成功!欢迎使用 {siteName}。',
reloginRequired: '会话已过期,请重新登录。', reloginRequired: '会话已过期,请重新登录。',
@@ -3698,6 +3700,11 @@ export default {
enableRegistrationHint: '允许新用户注册', enableRegistrationHint: '允许新用户注册',
emailVerification: '邮箱验证', emailVerification: '邮箱验证',
emailVerificationHint: '新用户注册时需要验证邮箱', emailVerificationHint: '新用户注册时需要验证邮箱',
emailSuffixWhitelist: '邮箱域名白名单',
emailSuffixWhitelistHint:
'仅允许使用指定域名的邮箱注册账号(例如 @qq.com, @gmail.com',
emailSuffixWhitelistPlaceholder: 'example.com',
emailSuffixWhitelistInputHint: '留空则不限制',
promoCode: '优惠码', promoCode: '优惠码',
promoCodeHint: '允许用户在注册时使用优惠码', promoCodeHint: '允许用户在注册时使用优惠码',
invitationCode: '邀请码注册', invitationCode: '邀请码注册',

View File

@@ -312,6 +312,7 @@ export const useAppStore = defineStore('app', () => {
return { return {
registration_enabled: false, registration_enabled: false,
email_verify_enabled: false, email_verify_enabled: false,
registration_email_suffix_whitelist: [],
promo_code_enabled: true, promo_code_enabled: true,
password_reset_enabled: false, password_reset_enabled: false,
invitation_code_enabled: false, invitation_code_enabled: false,

View File

@@ -87,6 +87,7 @@ export interface CustomMenuItem {
export interface PublicSettings { export interface PublicSettings {
registration_enabled: boolean registration_enabled: boolean
email_verify_enabled: boolean email_verify_enabled: boolean
registration_email_suffix_whitelist: string[]
promo_code_enabled: boolean promo_code_enabled: boolean
password_reset_enabled: boolean password_reset_enabled: boolean
invitation_code_enabled: boolean invitation_code_enabled: boolean

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { buildAuthErrorMessage } from '@/utils/authError'
describe('buildAuthErrorMessage', () => {
it('prefers response detail message when available', () => {
const message = buildAuthErrorMessage(
{
response: {
data: {
detail: 'detailed message',
message: 'plain message'
}
},
},
{ fallback: 'fallback' }
)
expect(message).toBe('detailed message')
})
it('falls back to response message when detail is unavailable', () => {
const message = buildAuthErrorMessage(
{
response: {
data: {
message: 'plain message'
}
},
},
{ fallback: 'fallback' }
)
expect(message).toBe('plain message')
})
it('falls back to error.message when response payload is unavailable', () => {
const message = buildAuthErrorMessage(
{
message: 'error message'
},
{ fallback: 'fallback' }
)
expect(message).toBe('error message')
})
it('uses fallback when no message can be extracted', () => {
expect(buildAuthErrorMessage({}, { fallback: 'fallback' })).toBe('fallback')
})
})

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import {
isRegistrationEmailSuffixAllowed,
isRegistrationEmailSuffixDomainValid,
normalizeRegistrationEmailSuffixDomain,
normalizeRegistrationEmailSuffixDomains,
normalizeRegistrationEmailSuffixWhitelist,
parseRegistrationEmailSuffixWhitelistInput
} from '@/utils/registrationEmailPolicy'
describe('registrationEmailPolicy utils', () => {
it('normalizeRegistrationEmailSuffixDomain lowercases, strips @, and ignores invalid chars', () => {
expect(normalizeRegistrationEmailSuffixDomain(' @Exa!mple.COM ')).toBe('example.com')
})
it('normalizeRegistrationEmailSuffixDomains deduplicates normalized domains', () => {
expect(
normalizeRegistrationEmailSuffixDomains([
'@example.com',
'Example.com',
'',
'-invalid.com',
'foo..bar.com',
' @foo.bar ',
'@foo.bar'
])
).toEqual(['example.com', 'foo.bar'])
})
it('parseRegistrationEmailSuffixWhitelistInput supports separators and deduplicates', () => {
const input = '\n @example.com,example.com@foo.bar\t@FOO.bar '
expect(parseRegistrationEmailSuffixWhitelistInput(input)).toEqual(['example.com', 'foo.bar'])
})
it('parseRegistrationEmailSuffixWhitelistInput drops tokens containing invalid chars', () => {
const input = '@exa!mple.com, @foo.bar, @bad#token.com, @ok-domain.com'
expect(parseRegistrationEmailSuffixWhitelistInput(input)).toEqual(['foo.bar', 'ok-domain.com'])
})
it('parseRegistrationEmailSuffixWhitelistInput drops structurally invalid domains', () => {
const input = '@-bad.com, @foo..bar.com, @foo.bar, @xn--ok.com'
expect(parseRegistrationEmailSuffixWhitelistInput(input)).toEqual(['foo.bar', 'xn--ok.com'])
})
it('parseRegistrationEmailSuffixWhitelistInput returns empty list for blank input', () => {
expect(parseRegistrationEmailSuffixWhitelistInput(' \n \n')).toEqual([])
})
it('normalizeRegistrationEmailSuffixWhitelist returns canonical @domain list', () => {
expect(
normalizeRegistrationEmailSuffixWhitelist([
'@Example.com',
'foo.bar',
'',
'-invalid.com',
' @foo.bar '
])
).toEqual(['@example.com', '@foo.bar'])
})
it('isRegistrationEmailSuffixDomainValid matches backend-compatible domain rules', () => {
expect(isRegistrationEmailSuffixDomainValid('example.com')).toBe(true)
expect(isRegistrationEmailSuffixDomainValid('foo-bar.example.com')).toBe(true)
expect(isRegistrationEmailSuffixDomainValid('-bad.com')).toBe(false)
expect(isRegistrationEmailSuffixDomainValid('foo..bar.com')).toBe(false)
expect(isRegistrationEmailSuffixDomainValid('localhost')).toBe(false)
})
it('isRegistrationEmailSuffixAllowed allows any email when whitelist is empty', () => {
expect(isRegistrationEmailSuffixAllowed('user@example.com', [])).toBe(true)
})
it('isRegistrationEmailSuffixAllowed applies exact suffix matching', () => {
expect(isRegistrationEmailSuffixAllowed('user@example.com', ['@example.com'])).toBe(true)
expect(isRegistrationEmailSuffixAllowed('user@sub.example.com', ['@example.com'])).toBe(false)
})
})

View File

@@ -0,0 +1,25 @@
interface APIErrorLike {
message?: string
response?: {
data?: {
detail?: string
message?: string
}
}
}
function extractErrorMessage(error: unknown): string {
const err = (error || {}) as APIErrorLike
return err.response?.data?.detail || err.response?.data?.message || err.message || ''
}
export function buildAuthErrorMessage(
error: unknown,
options: {
fallback: string
}
): string {
const { fallback } = options
const message = extractErrorMessage(error)
return message || fallback
}

View File

@@ -0,0 +1,115 @@
const EMAIL_SUFFIX_TOKEN_SPLIT_RE = /[\s,]+/
const EMAIL_SUFFIX_INVALID_CHAR_RE = /[^a-z0-9.-]/g
const EMAIL_SUFFIX_INVALID_CHAR_CHECK_RE = /[^a-z0-9.-]/
const EMAIL_SUFFIX_PREFIX_RE = /^@+/
const EMAIL_SUFFIX_DOMAIN_PATTERN =
/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/
// normalizeRegistrationEmailSuffixDomain converts raw input into a canonical domain token.
// It removes leading "@", lowercases input, and strips all invalid characters.
export function normalizeRegistrationEmailSuffixDomain(raw: string): string {
let value = String(raw || '').trim().toLowerCase()
if (!value) {
return ''
}
value = value.replace(EMAIL_SUFFIX_PREFIX_RE, '')
value = value.replace(EMAIL_SUFFIX_INVALID_CHAR_RE, '')
return value
}
export function normalizeRegistrationEmailSuffixDomains(
items: string[] | null | undefined
): string[] {
if (!items || items.length === 0) {
return []
}
const seen = new Set<string>()
const normalized: string[] = []
for (const item of items) {
const domain = normalizeRegistrationEmailSuffixDomain(item)
if (!isRegistrationEmailSuffixDomainValid(domain) || seen.has(domain)) {
continue
}
seen.add(domain)
normalized.push(domain)
}
return normalized
}
export function parseRegistrationEmailSuffixWhitelistInput(input: string): string[] {
if (!input || !input.trim()) {
return []
}
const seen = new Set<string>()
const normalized: string[] = []
for (const token of input.split(EMAIL_SUFFIX_TOKEN_SPLIT_RE)) {
const domain = normalizeRegistrationEmailSuffixDomainStrict(token)
if (!isRegistrationEmailSuffixDomainValid(domain) || seen.has(domain)) {
continue
}
seen.add(domain)
normalized.push(domain)
}
return normalized
}
export function normalizeRegistrationEmailSuffixWhitelist(
items: string[] | null | undefined
): string[] {
return normalizeRegistrationEmailSuffixDomains(items).map((domain) => `@${domain}`)
}
function extractRegistrationEmailDomain(email: string): string {
const raw = String(email || '').trim().toLowerCase()
if (!raw) {
return ''
}
const atIndex = raw.indexOf('@')
if (atIndex <= 0 || atIndex >= raw.length - 1) {
return ''
}
if (raw.indexOf('@', atIndex + 1) !== -1) {
return ''
}
return raw.slice(atIndex + 1)
}
export function isRegistrationEmailSuffixAllowed(
email: string,
whitelist: string[] | null | undefined
): boolean {
const normalizedWhitelist = normalizeRegistrationEmailSuffixWhitelist(whitelist)
if (normalizedWhitelist.length === 0) {
return true
}
const emailDomain = extractRegistrationEmailDomain(email)
if (!emailDomain) {
return false
}
const emailSuffix = `@${emailDomain}`
return normalizedWhitelist.includes(emailSuffix)
}
// Pasted domains should be strict: any invalid character drops the whole token.
function normalizeRegistrationEmailSuffixDomainStrict(raw: string): string {
let value = String(raw || '').trim().toLowerCase()
if (!value) {
return ''
}
value = value.replace(EMAIL_SUFFIX_PREFIX_RE, '')
if (!value || EMAIL_SUFFIX_INVALID_CHAR_CHECK_RE.test(value)) {
return ''
}
return value
}
export function isRegistrationEmailSuffixDomainValid(domain: string): boolean {
if (!domain) {
return false
}
return EMAIL_SUFFIX_DOMAIN_PATTERN.test(domain)
}

View File

@@ -324,6 +324,56 @@
<Toggle v-model="form.email_verify_enabled" /> <Toggle v-model="form.email_verify_enabled" />
</div> </div>
<!-- Email Suffix Whitelist -->
<div class="border-t border-gray-100 pt-4 dark:border-dark-700">
<label class="font-medium text-gray-900 dark:text-white">{{
t('admin.settings.registration.emailSuffixWhitelist')
}}</label>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.registration.emailSuffixWhitelistHint') }}
</p>
<div
class="mt-3 rounded-lg border border-gray-300 bg-white p-2 dark:border-dark-500 dark:bg-dark-700"
>
<div class="flex flex-wrap items-center gap-2">
<span
v-for="suffix in registrationEmailSuffixWhitelistTags"
:key="suffix"
class="inline-flex items-center gap-1 rounded bg-gray-100 px-2 py-1 text-xs font-mono text-gray-700 dark:bg-dark-600 dark:text-gray-200"
>
<span class="text-gray-400 dark:text-gray-500">@</span>
<span>{{ suffix }}</span>
<button
type="button"
class="rounded-full text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-300 dark:hover:bg-dark-500 dark:hover:text-white"
@click="removeRegistrationEmailSuffixWhitelistTag(suffix)"
>
<Icon name="x" size="xs" class="h-3.5 w-3.5" :stroke-width="2" />
</button>
</span>
<div
class="flex min-w-[220px] flex-1 items-center gap-1 rounded border border-transparent px-2 py-1 focus-within:border-primary-300 dark:focus-within:border-primary-700"
>
<span class="font-mono text-sm text-gray-400 dark:text-gray-500">@</span>
<input
v-model="registrationEmailSuffixWhitelistDraft"
type="text"
class="w-full bg-transparent text-sm font-mono text-gray-900 outline-none placeholder:text-gray-400 dark:text-white dark:placeholder:text-gray-500"
:placeholder="t('admin.settings.registration.emailSuffixWhitelistPlaceholder')"
@input="handleRegistrationEmailSuffixWhitelistDraftInput"
@keydown="handleRegistrationEmailSuffixWhitelistDraftKeydown"
@blur="commitRegistrationEmailSuffixWhitelistDraft"
@paste="handleRegistrationEmailSuffixWhitelistPaste"
/>
</div>
</div>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.registration.emailSuffixWhitelistInputHint') }}
</p>
</div>
<!-- Promo Code --> <!-- Promo Code -->
<div <div
class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700" class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
@@ -1364,6 +1414,12 @@ import ImageUpload from '@/components/common/ImageUpload.vue'
import { useClipboard } from '@/composables/useClipboard' import { useClipboard } from '@/composables/useClipboard'
import { useAppStore } from '@/stores' import { useAppStore } from '@/stores'
import { useAdminSettingsStore } from '@/stores/adminSettings' import { useAdminSettingsStore } from '@/stores/adminSettings'
import {
isRegistrationEmailSuffixDomainValid,
normalizeRegistrationEmailSuffixDomain,
normalizeRegistrationEmailSuffixDomains,
parseRegistrationEmailSuffixWhitelistInput
} from '@/utils/registrationEmailPolicy'
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
@@ -1375,6 +1431,9 @@ const saving = ref(false)
const testingSmtp = ref(false) const testingSmtp = ref(false)
const sendingTestEmail = ref(false) const sendingTestEmail = ref(false)
const testEmailAddress = ref('') const testEmailAddress = ref('')
const logoError = ref('')
const registrationEmailSuffixWhitelistTags = ref<string[]>([])
const registrationEmailSuffixWhitelistDraft = ref('')
// Admin API Key 状态 // Admin API Key 状态
const adminApiKeyLoading = ref(true) const adminApiKeyLoading = ref(true)
@@ -1414,6 +1473,7 @@ type SettingsForm = SystemSettings & {
const form = reactive<SettingsForm>({ const form = reactive<SettingsForm>({
registration_enabled: true, registration_enabled: true,
email_verify_enabled: false, email_verify_enabled: false,
registration_email_suffix_whitelist: [],
promo_code_enabled: true, promo_code_enabled: true,
invitation_code_enabled: false, invitation_code_enabled: false,
password_reset_enabled: false, password_reset_enabled: false,
@@ -1484,6 +1544,74 @@ const defaultSubscriptionGroupOptions = computed<DefaultSubscriptionGroupOption[
})) }))
) )
const registrationEmailSuffixWhitelistSeparatorKeys = new Set([' ', ',', '', 'Enter', 'Tab'])
function removeRegistrationEmailSuffixWhitelistTag(suffix: string) {
registrationEmailSuffixWhitelistTags.value = registrationEmailSuffixWhitelistTags.value.filter(
(item) => item !== suffix
)
}
function addRegistrationEmailSuffixWhitelistTag(raw: string) {
const suffix = normalizeRegistrationEmailSuffixDomain(raw)
if (
!isRegistrationEmailSuffixDomainValid(suffix) ||
registrationEmailSuffixWhitelistTags.value.includes(suffix)
) {
return
}
registrationEmailSuffixWhitelistTags.value = [
...registrationEmailSuffixWhitelistTags.value,
suffix
]
}
function commitRegistrationEmailSuffixWhitelistDraft() {
if (!registrationEmailSuffixWhitelistDraft.value) {
return
}
addRegistrationEmailSuffixWhitelistTag(registrationEmailSuffixWhitelistDraft.value)
registrationEmailSuffixWhitelistDraft.value = ''
}
function handleRegistrationEmailSuffixWhitelistDraftInput() {
registrationEmailSuffixWhitelistDraft.value = normalizeRegistrationEmailSuffixDomain(
registrationEmailSuffixWhitelistDraft.value
)
}
function handleRegistrationEmailSuffixWhitelistDraftKeydown(event: KeyboardEvent) {
if (event.isComposing) {
return
}
if (registrationEmailSuffixWhitelistSeparatorKeys.has(event.key)) {
event.preventDefault()
commitRegistrationEmailSuffixWhitelistDraft()
return
}
if (
event.key === 'Backspace' &&
!registrationEmailSuffixWhitelistDraft.value &&
registrationEmailSuffixWhitelistTags.value.length > 0
) {
registrationEmailSuffixWhitelistTags.value.pop()
}
}
function handleRegistrationEmailSuffixWhitelistPaste(event: ClipboardEvent) {
const text = event.clipboardData?.getData('text') || ''
if (!text.trim()) {
return
}
event.preventDefault()
const tokens = parseRegistrationEmailSuffixWhitelistInput(text)
for (const token of tokens) {
addRegistrationEmailSuffixWhitelistTag(token)
}
}
// LinuxDo OAuth redirect URL suggestion // LinuxDo OAuth redirect URL suggestion
const linuxdoRedirectUrlSuggestion = computed(() => { const linuxdoRedirectUrlSuggestion = computed(() => {
if (typeof window === 'undefined') return '' if (typeof window === 'undefined') return ''
@@ -1546,6 +1674,10 @@ async function loadSettings() {
validity_days: item.validity_days validity_days: item.validity_days
})) }))
: [] : []
registrationEmailSuffixWhitelistTags.value = normalizeRegistrationEmailSuffixDomains(
settings.registration_email_suffix_whitelist
)
registrationEmailSuffixWhitelistDraft.value = ''
form.smtp_password = '' form.smtp_password = ''
form.turnstile_secret_key = '' form.turnstile_secret_key = ''
form.linuxdo_connect_client_secret = '' form.linuxdo_connect_client_secret = ''
@@ -1615,6 +1747,9 @@ async function saveSettings() {
const payload: UpdateSettingsRequest = { const payload: UpdateSettingsRequest = {
registration_enabled: form.registration_enabled, registration_enabled: form.registration_enabled,
email_verify_enabled: form.email_verify_enabled, email_verify_enabled: form.email_verify_enabled,
registration_email_suffix_whitelist: registrationEmailSuffixWhitelistTags.value.map(
(suffix) => `@${suffix}`
),
promo_code_enabled: form.promo_code_enabled, promo_code_enabled: form.promo_code_enabled,
invitation_code_enabled: form.invitation_code_enabled, invitation_code_enabled: form.invitation_code_enabled,
password_reset_enabled: form.password_reset_enabled, password_reset_enabled: form.password_reset_enabled,
@@ -1660,6 +1795,10 @@ async function saveSettings() {
} }
const updated = await adminAPI.settings.updateSettings(payload) const updated = await adminAPI.settings.updateSettings(payload)
Object.assign(form, updated) Object.assign(form, updated)
registrationEmailSuffixWhitelistTags.value = normalizeRegistrationEmailSuffixDomains(
updated.registration_email_suffix_whitelist
)
registrationEmailSuffixWhitelistDraft.value = ''
form.smtp_password = '' form.smtp_password = ''
form.turnstile_secret_key = '' form.turnstile_secret_key = ''
form.linuxdo_connect_client_secret = '' form.linuxdo_connect_client_secret = ''

View File

@@ -177,8 +177,13 @@ import Icon from '@/components/icons/Icon.vue'
import TurnstileWidget from '@/components/TurnstileWidget.vue' import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAuthStore, useAppStore } from '@/stores' import { useAuthStore, useAppStore } from '@/stores'
import { getPublicSettings, sendVerifyCode } from '@/api/auth' import { getPublicSettings, sendVerifyCode } from '@/api/auth'
import { buildAuthErrorMessage } from '@/utils/authError'
import {
isRegistrationEmailSuffixAllowed,
normalizeRegistrationEmailSuffixWhitelist
} from '@/utils/registrationEmailPolicy'
const { t } = useI18n() const { t, locale } = useI18n()
// ==================== Router & Stores ==================== // ==================== Router & Stores ====================
@@ -208,6 +213,7 @@ const hasRegisterData = ref<boolean>(false)
const turnstileEnabled = ref<boolean>(false) const turnstileEnabled = ref<boolean>(false)
const turnstileSiteKey = ref<string>('') const turnstileSiteKey = ref<string>('')
const siteName = ref<string>('Sub2API') const siteName = ref<string>('Sub2API')
const registrationEmailSuffixWhitelist = ref<string[]>([])
// Turnstile for resend // Turnstile for resend
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null) const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
@@ -244,6 +250,9 @@ onMounted(async () => {
turnstileEnabled.value = settings.turnstile_enabled turnstileEnabled.value = settings.turnstile_enabled
turnstileSiteKey.value = settings.turnstile_site_key || '' turnstileSiteKey.value = settings.turnstile_site_key || ''
siteName.value = settings.site_name || 'Sub2API' siteName.value = settings.site_name || 'Sub2API'
registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist(
settings.registration_email_suffix_whitelist || []
)
} catch (error) { } catch (error) {
console.error('Failed to load public settings:', error) console.error('Failed to load public settings:', error)
} }
@@ -306,6 +315,12 @@ async function sendCode(): Promise<void> {
errorMessage.value = '' errorMessage.value = ''
try { try {
if (!isRegistrationEmailSuffixAllowed(email.value, registrationEmailSuffixWhitelist.value)) {
errorMessage.value = buildEmailSuffixNotAllowedMessage()
appStore.showError(errorMessage.value)
return
}
const response = await sendVerifyCode({ const response = await sendVerifyCode({
email: email.value, email: email.value,
// 优先使用重发时新获取的 token因为初始 token 可能已被使用) // 优先使用重发时新获取的 token因为初始 token 可能已被使用)
@@ -320,15 +335,9 @@ async function sendCode(): Promise<void> {
showResendTurnstile.value = false showResendTurnstile.value = false
resendTurnstileToken.value = '' resendTurnstileToken.value = ''
} catch (error: unknown) { } catch (error: unknown) {
const err = error as { message?: string; response?: { data?: { detail?: string } } } errorMessage.value = buildAuthErrorMessage(error, {
fallback: 'Failed to send verification code. Please try again.'
if (err.response?.data?.detail) { })
errorMessage.value = err.response.data.detail
} else if (err.message) {
errorMessage.value = err.message
} else {
errorMessage.value = 'Failed to send verification code. Please try again.'
}
appStore.showError(errorMessage.value) appStore.showError(errorMessage.value)
} finally { } finally {
@@ -380,6 +389,12 @@ async function handleVerify(): Promise<void> {
isLoading.value = true isLoading.value = true
try { try {
if (!isRegistrationEmailSuffixAllowed(email.value, registrationEmailSuffixWhitelist.value)) {
errorMessage.value = buildEmailSuffixNotAllowedMessage()
appStore.showError(errorMessage.value)
return
}
// Register with verification code // Register with verification code
await authStore.register({ await authStore.register({
email: email.value, email: email.value,
@@ -399,15 +414,9 @@ async function handleVerify(): Promise<void> {
// Redirect to dashboard // Redirect to dashboard
await router.push('/dashboard') await router.push('/dashboard')
} catch (error: unknown) { } catch (error: unknown) {
const err = error as { message?: string; response?: { data?: { detail?: string } } } errorMessage.value = buildAuthErrorMessage(error, {
fallback: 'Verification failed. Please try again.'
if (err.response?.data?.detail) { })
errorMessage.value = err.response.data.detail
} else if (err.message) {
errorMessage.value = err.message
} else {
errorMessage.value = 'Verification failed. Please try again.'
}
appStore.showError(errorMessage.value) appStore.showError(errorMessage.value)
} finally { } finally {
@@ -422,6 +431,19 @@ function handleBack(): void {
// Go back to registration // Go back to registration
router.push('/register') 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: normalizedWhitelist.join(separator)
})
}
</script> </script>
<style scoped> <style scoped>

View File

@@ -293,8 +293,13 @@ import Icon from '@/components/icons/Icon.vue'
import TurnstileWidget from '@/components/TurnstileWidget.vue' import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAuthStore, useAppStore } from '@/stores' import { useAuthStore, useAppStore } from '@/stores'
import { getPublicSettings, validatePromoCode, validateInvitationCode } from '@/api/auth' import { getPublicSettings, validatePromoCode, validateInvitationCode } from '@/api/auth'
import { buildAuthErrorMessage } from '@/utils/authError'
import {
isRegistrationEmailSuffixAllowed,
normalizeRegistrationEmailSuffixWhitelist
} from '@/utils/registrationEmailPolicy'
const { t } = useI18n() const { t, locale } = useI18n()
// ==================== Router & Stores ==================== // ==================== Router & Stores ====================
@@ -319,6 +324,7 @@ const turnstileEnabled = ref<boolean>(false)
const turnstileSiteKey = ref<string>('') const turnstileSiteKey = ref<string>('')
const siteName = ref<string>('Sub2API') const siteName = ref<string>('Sub2API')
const linuxdoOAuthEnabled = ref<boolean>(false) const linuxdoOAuthEnabled = ref<boolean>(false)
const registrationEmailSuffixWhitelist = ref<string[]>([])
// Turnstile // Turnstile
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null) const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
@@ -370,6 +376,9 @@ onMounted(async () => {
turnstileSiteKey.value = settings.turnstile_site_key || '' turnstileSiteKey.value = settings.turnstile_site_key || ''
siteName.value = settings.site_name || 'Sub2API' siteName.value = settings.site_name || 'Sub2API'
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist(
settings.registration_email_suffix_whitelist || []
)
// Read promo code from URL parameter only if promo code is enabled // Read promo code from URL parameter only if promo code is enabled
if (promoCodeEnabled.value) { if (promoCodeEnabled.value) {
@@ -557,6 +566,19 @@ function validateEmail(email: string): boolean {
return emailRegex.test(email) 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: normalizedWhitelist.join(separator)
})
}
function validateForm(): boolean { function validateForm(): boolean {
// Reset errors // Reset errors
errors.email = '' errors.email = ''
@@ -573,6 +595,11 @@ function validateForm(): boolean {
} else if (!validateEmail(formData.email)) { } else if (!validateEmail(formData.email)) {
errors.email = t('auth.invalidEmail') errors.email = t('auth.invalidEmail')
isValid = false isValid = false
} else if (
!isRegistrationEmailSuffixAllowed(formData.email, registrationEmailSuffixWhitelist.value)
) {
errors.email = buildEmailSuffixNotAllowedMessage()
isValid = false
} }
// Password validation // Password validation
@@ -694,15 +721,9 @@ async function handleRegister(): Promise<void> {
} }
// Handle registration error // Handle registration error
const err = error as { message?: string; response?: { data?: { detail?: string } } } errorMessage.value = buildAuthErrorMessage(error, {
fallback: t('auth.registrationFailed')
if (err.response?.data?.detail) { })
errorMessage.value = err.response.data.detail
} else if (err.message) {
errorMessage.value = err.message
} else {
errorMessage.value = t('auth.registrationFailed')
}
// Also show error toast // Also show error toast
appStore.showError(errorMessage.value) appStore.showError(errorMessage.value)