feat(registration): add email domain whitelist policy
This commit is contained in:
@@ -77,6 +77,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
response.Success(c, dto.SystemSettings{
|
||||
RegistrationEnabled: settings.RegistrationEnabled,
|
||||
EmailVerifyEnabled: settings.EmailVerifyEnabled,
|
||||
RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist,
|
||||
PromoCodeEnabled: settings.PromoCodeEnabled,
|
||||
PasswordResetEnabled: settings.PasswordResetEnabled,
|
||||
InvitationCodeEnabled: settings.InvitationCodeEnabled,
|
||||
@@ -130,12 +131,13 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
// UpdateSettingsRequest 更新设置请求
|
||||
type UpdateSettingsRequest struct {
|
||||
// 注册设置
|
||||
RegistrationEnabled bool `json:"registration_enabled"`
|
||||
EmailVerifyEnabled bool `json:"email_verify_enabled"`
|
||||
PromoCodeEnabled bool `json:"promo_code_enabled"`
|
||||
PasswordResetEnabled bool `json:"password_reset_enabled"`
|
||||
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
|
||||
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
|
||||
RegistrationEnabled bool `json:"registration_enabled"`
|
||||
EmailVerifyEnabled bool `json:"email_verify_enabled"`
|
||||
RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
|
||||
PromoCodeEnabled bool `json:"promo_code_enabled"`
|
||||
PasswordResetEnabled bool `json:"password_reset_enabled"`
|
||||
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
|
||||
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
|
||||
|
||||
// 邮件服务设置
|
||||
SMTPHost string `json:"smtp_host"`
|
||||
@@ -426,50 +428,51 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
|
||||
settings := &service.SystemSettings{
|
||||
RegistrationEnabled: req.RegistrationEnabled,
|
||||
EmailVerifyEnabled: req.EmailVerifyEnabled,
|
||||
PromoCodeEnabled: req.PromoCodeEnabled,
|
||||
PasswordResetEnabled: req.PasswordResetEnabled,
|
||||
InvitationCodeEnabled: req.InvitationCodeEnabled,
|
||||
TotpEnabled: req.TotpEnabled,
|
||||
SMTPHost: req.SMTPHost,
|
||||
SMTPPort: req.SMTPPort,
|
||||
SMTPUsername: req.SMTPUsername,
|
||||
SMTPPassword: req.SMTPPassword,
|
||||
SMTPFrom: req.SMTPFrom,
|
||||
SMTPFromName: req.SMTPFromName,
|
||||
SMTPUseTLS: req.SMTPUseTLS,
|
||||
TurnstileEnabled: req.TurnstileEnabled,
|
||||
TurnstileSiteKey: req.TurnstileSiteKey,
|
||||
TurnstileSecretKey: req.TurnstileSecretKey,
|
||||
LinuxDoConnectEnabled: req.LinuxDoConnectEnabled,
|
||||
LinuxDoConnectClientID: req.LinuxDoConnectClientID,
|
||||
LinuxDoConnectClientSecret: req.LinuxDoConnectClientSecret,
|
||||
LinuxDoConnectRedirectURL: req.LinuxDoConnectRedirectURL,
|
||||
SiteName: req.SiteName,
|
||||
SiteLogo: req.SiteLogo,
|
||||
SiteSubtitle: req.SiteSubtitle,
|
||||
APIBaseURL: req.APIBaseURL,
|
||||
ContactInfo: req.ContactInfo,
|
||||
DocURL: req.DocURL,
|
||||
HomeContent: req.HomeContent,
|
||||
HideCcsImportButton: req.HideCcsImportButton,
|
||||
PurchaseSubscriptionEnabled: purchaseEnabled,
|
||||
PurchaseSubscriptionURL: purchaseURL,
|
||||
SoraClientEnabled: req.SoraClientEnabled,
|
||||
CustomMenuItems: customMenuJSON,
|
||||
DefaultConcurrency: req.DefaultConcurrency,
|
||||
DefaultBalance: req.DefaultBalance,
|
||||
DefaultSubscriptions: defaultSubscriptions,
|
||||
EnableModelFallback: req.EnableModelFallback,
|
||||
FallbackModelAnthropic: req.FallbackModelAnthropic,
|
||||
FallbackModelOpenAI: req.FallbackModelOpenAI,
|
||||
FallbackModelGemini: req.FallbackModelGemini,
|
||||
FallbackModelAntigravity: req.FallbackModelAntigravity,
|
||||
EnableIdentityPatch: req.EnableIdentityPatch,
|
||||
IdentityPatchPrompt: req.IdentityPatchPrompt,
|
||||
MinClaudeCodeVersion: req.MinClaudeCodeVersion,
|
||||
AllowUngroupedKeyScheduling: req.AllowUngroupedKeyScheduling,
|
||||
RegistrationEnabled: req.RegistrationEnabled,
|
||||
EmailVerifyEnabled: req.EmailVerifyEnabled,
|
||||
RegistrationEmailSuffixWhitelist: req.RegistrationEmailSuffixWhitelist,
|
||||
PromoCodeEnabled: req.PromoCodeEnabled,
|
||||
PasswordResetEnabled: req.PasswordResetEnabled,
|
||||
InvitationCodeEnabled: req.InvitationCodeEnabled,
|
||||
TotpEnabled: req.TotpEnabled,
|
||||
SMTPHost: req.SMTPHost,
|
||||
SMTPPort: req.SMTPPort,
|
||||
SMTPUsername: req.SMTPUsername,
|
||||
SMTPPassword: req.SMTPPassword,
|
||||
SMTPFrom: req.SMTPFrom,
|
||||
SMTPFromName: req.SMTPFromName,
|
||||
SMTPUseTLS: req.SMTPUseTLS,
|
||||
TurnstileEnabled: req.TurnstileEnabled,
|
||||
TurnstileSiteKey: req.TurnstileSiteKey,
|
||||
TurnstileSecretKey: req.TurnstileSecretKey,
|
||||
LinuxDoConnectEnabled: req.LinuxDoConnectEnabled,
|
||||
LinuxDoConnectClientID: req.LinuxDoConnectClientID,
|
||||
LinuxDoConnectClientSecret: req.LinuxDoConnectClientSecret,
|
||||
LinuxDoConnectRedirectURL: req.LinuxDoConnectRedirectURL,
|
||||
SiteName: req.SiteName,
|
||||
SiteLogo: req.SiteLogo,
|
||||
SiteSubtitle: req.SiteSubtitle,
|
||||
APIBaseURL: req.APIBaseURL,
|
||||
ContactInfo: req.ContactInfo,
|
||||
DocURL: req.DocURL,
|
||||
HomeContent: req.HomeContent,
|
||||
HideCcsImportButton: req.HideCcsImportButton,
|
||||
PurchaseSubscriptionEnabled: purchaseEnabled,
|
||||
PurchaseSubscriptionURL: purchaseURL,
|
||||
SoraClientEnabled: req.SoraClientEnabled,
|
||||
CustomMenuItems: customMenuJSON,
|
||||
DefaultConcurrency: req.DefaultConcurrency,
|
||||
DefaultBalance: req.DefaultBalance,
|
||||
DefaultSubscriptions: defaultSubscriptions,
|
||||
EnableModelFallback: req.EnableModelFallback,
|
||||
FallbackModelAnthropic: req.FallbackModelAnthropic,
|
||||
FallbackModelOpenAI: req.FallbackModelOpenAI,
|
||||
FallbackModelGemini: req.FallbackModelGemini,
|
||||
FallbackModelAntigravity: req.FallbackModelAntigravity,
|
||||
EnableIdentityPatch: req.EnableIdentityPatch,
|
||||
IdentityPatchPrompt: req.IdentityPatchPrompt,
|
||||
MinClaudeCodeVersion: req.MinClaudeCodeVersion,
|
||||
AllowUngroupedKeyScheduling: req.AllowUngroupedKeyScheduling,
|
||||
OpsMonitoringEnabled: func() bool {
|
||||
if req.OpsMonitoringEnabled != nil {
|
||||
return *req.OpsMonitoringEnabled
|
||||
@@ -520,6 +523,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
response.Success(c, dto.SystemSettings{
|
||||
RegistrationEnabled: updatedSettings.RegistrationEnabled,
|
||||
EmailVerifyEnabled: updatedSettings.EmailVerifyEnabled,
|
||||
RegistrationEmailSuffixWhitelist: updatedSettings.RegistrationEmailSuffixWhitelist,
|
||||
PromoCodeEnabled: updatedSettings.PromoCodeEnabled,
|
||||
PasswordResetEnabled: updatedSettings.PasswordResetEnabled,
|
||||
InvitationCodeEnabled: updatedSettings.InvitationCodeEnabled,
|
||||
@@ -598,6 +602,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
||||
if before.EmailVerifyEnabled != after.EmailVerifyEnabled {
|
||||
changed = append(changed, "email_verify_enabled")
|
||||
}
|
||||
if !equalStringSlice(before.RegistrationEmailSuffixWhitelist, after.RegistrationEmailSuffixWhitelist) {
|
||||
changed = append(changed, "registration_email_suffix_whitelist")
|
||||
}
|
||||
if before.PasswordResetEnabled != after.PasswordResetEnabled {
|
||||
changed = append(changed, "password_reset_enabled")
|
||||
}
|
||||
@@ -747,6 +754,18 @@ func normalizeDefaultSubscriptions(input []dto.DefaultSubscriptionSetting) []dto
|
||||
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 {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
|
||||
@@ -17,13 +17,14 @@ type CustomMenuItem struct {
|
||||
|
||||
// SystemSettings represents the admin settings API response payload.
|
||||
type SystemSettings struct {
|
||||
RegistrationEnabled bool `json:"registration_enabled"`
|
||||
EmailVerifyEnabled bool `json:"email_verify_enabled"`
|
||||
PromoCodeEnabled bool `json:"promo_code_enabled"`
|
||||
PasswordResetEnabled bool `json:"password_reset_enabled"`
|
||||
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
|
||||
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
|
||||
TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // TOTP 加密密钥是否已配置
|
||||
RegistrationEnabled bool `json:"registration_enabled"`
|
||||
EmailVerifyEnabled bool `json:"email_verify_enabled"`
|
||||
RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
|
||||
PromoCodeEnabled bool `json:"promo_code_enabled"`
|
||||
PasswordResetEnabled bool `json:"password_reset_enabled"`
|
||||
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
|
||||
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
|
||||
TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // TOTP 加密密钥是否已配置
|
||||
|
||||
SMTPHost string `json:"smtp_host"`
|
||||
SMTPPort int `json:"smtp_port"`
|
||||
@@ -88,28 +89,29 @@ type DefaultSubscriptionSetting struct {
|
||||
}
|
||||
|
||||
type PublicSettings struct {
|
||||
RegistrationEnabled bool `json:"registration_enabled"`
|
||||
EmailVerifyEnabled bool `json:"email_verify_enabled"`
|
||||
PromoCodeEnabled bool `json:"promo_code_enabled"`
|
||||
PasswordResetEnabled bool `json:"password_reset_enabled"`
|
||||
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
|
||||
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
|
||||
TurnstileEnabled bool `json:"turnstile_enabled"`
|
||||
TurnstileSiteKey string `json:"turnstile_site_key"`
|
||||
SiteName string `json:"site_name"`
|
||||
SiteLogo string `json:"site_logo"`
|
||||
SiteSubtitle string `json:"site_subtitle"`
|
||||
APIBaseURL string `json:"api_base_url"`
|
||||
ContactInfo string `json:"contact_info"`
|
||||
DocURL string `json:"doc_url"`
|
||||
HomeContent string `json:"home_content"`
|
||||
HideCcsImportButton bool `json:"hide_ccs_import_button"`
|
||||
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
|
||||
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
|
||||
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
|
||||
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
||||
SoraClientEnabled bool `json:"sora_client_enabled"`
|
||||
Version string `json:"version"`
|
||||
RegistrationEnabled bool `json:"registration_enabled"`
|
||||
EmailVerifyEnabled bool `json:"email_verify_enabled"`
|
||||
RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
|
||||
PromoCodeEnabled bool `json:"promo_code_enabled"`
|
||||
PasswordResetEnabled bool `json:"password_reset_enabled"`
|
||||
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
|
||||
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
|
||||
TurnstileEnabled bool `json:"turnstile_enabled"`
|
||||
TurnstileSiteKey string `json:"turnstile_site_key"`
|
||||
SiteName string `json:"site_name"`
|
||||
SiteLogo string `json:"site_logo"`
|
||||
SiteSubtitle string `json:"site_subtitle"`
|
||||
APIBaseURL string `json:"api_base_url"`
|
||||
ContactInfo string `json:"contact_info"`
|
||||
DocURL string `json:"doc_url"`
|
||||
HomeContent string `json:"home_content"`
|
||||
HideCcsImportButton bool `json:"hide_ccs_import_button"`
|
||||
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
|
||||
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
|
||||
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
|
||||
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
||||
SoraClientEnabled bool `json:"sora_client_enabled"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// SoraS3Settings Sora S3 存储配置 DTO(响应用,不含敏感字段)
|
||||
|
||||
@@ -32,27 +32,28 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
||||
}
|
||||
|
||||
response.Success(c, dto.PublicSettings{
|
||||
RegistrationEnabled: settings.RegistrationEnabled,
|
||||
EmailVerifyEnabled: settings.EmailVerifyEnabled,
|
||||
PromoCodeEnabled: settings.PromoCodeEnabled,
|
||||
PasswordResetEnabled: settings.PasswordResetEnabled,
|
||||
InvitationCodeEnabled: settings.InvitationCodeEnabled,
|
||||
TotpEnabled: settings.TotpEnabled,
|
||||
TurnstileEnabled: settings.TurnstileEnabled,
|
||||
TurnstileSiteKey: settings.TurnstileSiteKey,
|
||||
SiteName: settings.SiteName,
|
||||
SiteLogo: settings.SiteLogo,
|
||||
SiteSubtitle: settings.SiteSubtitle,
|
||||
APIBaseURL: settings.APIBaseURL,
|
||||
ContactInfo: settings.ContactInfo,
|
||||
DocURL: settings.DocURL,
|
||||
HomeContent: settings.HomeContent,
|
||||
HideCcsImportButton: settings.HideCcsImportButton,
|
||||
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
|
||||
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
|
||||
CustomMenuItems: dto.ParseUserVisibleMenuItems(settings.CustomMenuItems),
|
||||
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
||||
SoraClientEnabled: settings.SoraClientEnabled,
|
||||
Version: h.version,
|
||||
RegistrationEnabled: settings.RegistrationEnabled,
|
||||
EmailVerifyEnabled: settings.EmailVerifyEnabled,
|
||||
RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist,
|
||||
PromoCodeEnabled: settings.PromoCodeEnabled,
|
||||
PasswordResetEnabled: settings.PasswordResetEnabled,
|
||||
InvitationCodeEnabled: settings.InvitationCodeEnabled,
|
||||
TotpEnabled: settings.TotpEnabled,
|
||||
TurnstileEnabled: settings.TurnstileEnabled,
|
||||
TurnstileSiteKey: settings.TurnstileSiteKey,
|
||||
SiteName: settings.SiteName,
|
||||
SiteLogo: settings.SiteLogo,
|
||||
SiteSubtitle: settings.SiteSubtitle,
|
||||
APIBaseURL: settings.APIBaseURL,
|
||||
ContactInfo: settings.ContactInfo,
|
||||
DocURL: settings.DocURL,
|
||||
HomeContent: settings.HomeContent,
|
||||
HideCcsImportButton: settings.HideCcsImportButton,
|
||||
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
|
||||
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
|
||||
CustomMenuItems: dto.ParseUserVisibleMenuItems(settings.CustomMenuItems),
|
||||
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
||||
SoraClientEnabled: settings.SoraClientEnabled,
|
||||
Version: h.version,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -446,9 +446,10 @@ func TestAPIContracts(t *testing.T) {
|
||||
setup: func(t *testing.T, deps *contractDeps) {
|
||||
t.Helper()
|
||||
deps.settingRepo.SetAll(map[string]string{
|
||||
service.SettingKeyRegistrationEnabled: "true",
|
||||
service.SettingKeyEmailVerifyEnabled: "false",
|
||||
service.SettingKeyPromoCodeEnabled: "true",
|
||||
service.SettingKeyRegistrationEnabled: "true",
|
||||
service.SettingKeyEmailVerifyEnabled: "false",
|
||||
service.SettingKeyRegistrationEmailSuffixWhitelist: "[]",
|
||||
service.SettingKeyPromoCodeEnabled: "true",
|
||||
|
||||
service.SettingKeySMTPHost: "smtp.example.com",
|
||||
service.SettingKeySMTPPort: "587",
|
||||
@@ -487,6 +488,7 @@ func TestAPIContracts(t *testing.T) {
|
||||
"data": {
|
||||
"registration_enabled": true,
|
||||
"email_verify_enabled": false,
|
||||
"registration_email_suffix_whitelist": [],
|
||||
"promo_code_enabled": true,
|
||||
"password_reset_enabled": false,
|
||||
"totp_enabled": false,
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -33,6 +34,7 @@ var (
|
||||
ErrRefreshTokenExpired = infraerrors.Unauthorized("REFRESH_TOKEN_EXPIRED", "refresh token has expired")
|
||||
ErrRefreshTokenReused = infraerrors.Unauthorized("REFRESH_TOKEN_REUSED", "refresh token has been reused")
|
||||
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")
|
||||
ErrServiceUnavailable = infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable")
|
||||
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) {
|
||||
return "", nil, ErrEmailReserved
|
||||
}
|
||||
if err := s.validateRegistrationEmailPolicy(ctx, email); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
// 检查是否需要邀请码
|
||||
var invitationRedeemCode *RedeemCode
|
||||
@@ -241,6 +246,9 @@ func (s *AuthService) SendVerifyCode(ctx context.Context, email string) error {
|
||||
if isReservedEmail(email) {
|
||||
return ErrEmailReserved
|
||||
}
|
||||
if err := s.validateRegistrationEmailPolicy(ctx, email); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查邮箱是否已存在
|
||||
existsEmail, err := s.userRepo.ExistsByEmail(ctx, email)
|
||||
@@ -279,6 +287,9 @@ func (s *AuthService) SendVerifyCodeAsync(ctx context.Context, email string) (*S
|
||||
if isReservedEmail(email) {
|
||||
return nil, ErrEmailReserved
|
||||
}
|
||||
if err := s.validateRegistrationEmailPolicy(ctx, email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 检查邮箱是否已存在
|
||||
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并返回用户声明
|
||||
func (s *AuthService) ValidateToken(tokenString string) (*JWTClaims, error) {
|
||||
// 先做长度校验,尽早拒绝异常超长 token,降低 DoS 风险。
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -231,6 +232,51 @@ func TestAuthService_Register_ReservedEmail(t *testing.T) {
|
||||
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) {
|
||||
repo := &userRepoStub{createErr: errors.New("create failed")}
|
||||
service := newAuthService(repo, map[string]string{
|
||||
@@ -402,7 +448,7 @@ func TestAuthService_Register_AssignsDefaultSubscriptions(t *testing.T) {
|
||||
repo := &userRepoStub{nextID: 42}
|
||||
assigner := &defaultSubscriptionAssignerStub{}
|
||||
service := newAuthService(repo, map[string]string{
|
||||
SettingKeyRegistrationEnabled: "true",
|
||||
SettingKeyRegistrationEnabled: "true",
|
||||
SettingKeyDefaultSubscriptions: `[{"group_id":11,"validity_days":30},{"group_id":12,"validity_days":7}]`,
|
||||
}, nil)
|
||||
service.defaultSubAssigner = assigner
|
||||
|
||||
@@ -74,11 +74,12 @@ const LinuxDoConnectSyntheticEmailDomain = "@linuxdo-connect.invalid"
|
||||
// Setting keys
|
||||
const (
|
||||
// 注册设置
|
||||
SettingKeyRegistrationEnabled = "registration_enabled" // 是否开放注册
|
||||
SettingKeyEmailVerifyEnabled = "email_verify_enabled" // 是否开启邮件验证
|
||||
SettingKeyPromoCodeEnabled = "promo_code_enabled" // 是否启用优惠码功能
|
||||
SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证)
|
||||
SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册
|
||||
SettingKeyRegistrationEnabled = "registration_enabled" // 是否开放注册
|
||||
SettingKeyEmailVerifyEnabled = "email_verify_enabled" // 是否开启邮件验证
|
||||
SettingKeyRegistrationEmailSuffixWhitelist = "registration_email_suffix_whitelist" // 注册邮箱后缀白名单(JSON 数组)
|
||||
SettingKeyPromoCodeEnabled = "promo_code_enabled" // 是否启用优惠码功能
|
||||
SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证)
|
||||
SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册
|
||||
|
||||
// 邮件服务设置
|
||||
SettingKeySMTPHost = "smtp_host" // SMTP服务器地址
|
||||
|
||||
123
backend/internal/service/registration_email_policy.go
Normal file
123
backend/internal/service/registration_email_policy.go
Normal 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
|
||||
}
|
||||
31
backend/internal/service/registration_email_policy_test.go
Normal file
31
backend/internal/service/registration_email_policy_test.go
Normal 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{}))
|
||||
}
|
||||
@@ -108,6 +108,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
||||
keys := []string{
|
||||
SettingKeyRegistrationEnabled,
|
||||
SettingKeyEmailVerifyEnabled,
|
||||
SettingKeyRegistrationEmailSuffixWhitelist,
|
||||
SettingKeyPromoCodeEnabled,
|
||||
SettingKeyPasswordResetEnabled,
|
||||
SettingKeyInvitationCodeEnabled,
|
||||
@@ -144,29 +145,33 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
||||
// Password reset requires email verification to be enabled
|
||||
emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true"
|
||||
passwordResetEnabled := emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true"
|
||||
registrationEmailSuffixWhitelist := ParseRegistrationEmailSuffixWhitelist(
|
||||
settings[SettingKeyRegistrationEmailSuffixWhitelist],
|
||||
)
|
||||
|
||||
return &PublicSettings{
|
||||
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
|
||||
EmailVerifyEnabled: emailVerifyEnabled,
|
||||
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
|
||||
PasswordResetEnabled: passwordResetEnabled,
|
||||
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
|
||||
TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
|
||||
TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true",
|
||||
TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
|
||||
SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"),
|
||||
SiteLogo: settings[SettingKeySiteLogo],
|
||||
SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"),
|
||||
APIBaseURL: settings[SettingKeyAPIBaseURL],
|
||||
ContactInfo: settings[SettingKeyContactInfo],
|
||||
DocURL: settings[SettingKeyDocURL],
|
||||
HomeContent: settings[SettingKeyHomeContent],
|
||||
HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true",
|
||||
PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
|
||||
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
|
||||
SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true",
|
||||
CustomMenuItems: settings[SettingKeyCustomMenuItems],
|
||||
LinuxDoOAuthEnabled: linuxDoEnabled,
|
||||
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
|
||||
EmailVerifyEnabled: emailVerifyEnabled,
|
||||
RegistrationEmailSuffixWhitelist: registrationEmailSuffixWhitelist,
|
||||
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
|
||||
PasswordResetEnabled: passwordResetEnabled,
|
||||
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
|
||||
TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
|
||||
TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true",
|
||||
TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
|
||||
SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"),
|
||||
SiteLogo: settings[SettingKeySiteLogo],
|
||||
SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"),
|
||||
APIBaseURL: settings[SettingKeyAPIBaseURL],
|
||||
ContactInfo: settings[SettingKeyContactInfo],
|
||||
DocURL: settings[SettingKeyDocURL],
|
||||
HomeContent: settings[SettingKeyHomeContent],
|
||||
HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true",
|
||||
PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
|
||||
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
|
||||
SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true",
|
||||
CustomMenuItems: settings[SettingKeyCustomMenuItems],
|
||||
LinuxDoOAuthEnabled: linuxDoEnabled,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -196,51 +201,53 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
|
||||
|
||||
// Return a struct that matches the frontend's expected format
|
||||
return &struct {
|
||||
RegistrationEnabled bool `json:"registration_enabled"`
|
||||
EmailVerifyEnabled bool `json:"email_verify_enabled"`
|
||||
PromoCodeEnabled bool `json:"promo_code_enabled"`
|
||||
PasswordResetEnabled bool `json:"password_reset_enabled"`
|
||||
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
|
||||
TotpEnabled bool `json:"totp_enabled"`
|
||||
TurnstileEnabled bool `json:"turnstile_enabled"`
|
||||
TurnstileSiteKey string `json:"turnstile_site_key,omitempty"`
|
||||
SiteName string `json:"site_name"`
|
||||
SiteLogo string `json:"site_logo,omitempty"`
|
||||
SiteSubtitle string `json:"site_subtitle,omitempty"`
|
||||
APIBaseURL string `json:"api_base_url,omitempty"`
|
||||
ContactInfo string `json:"contact_info,omitempty"`
|
||||
DocURL string `json:"doc_url,omitempty"`
|
||||
HomeContent string `json:"home_content,omitempty"`
|
||||
HideCcsImportButton bool `json:"hide_ccs_import_button"`
|
||||
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
|
||||
PurchaseSubscriptionURL string `json:"purchase_subscription_url,omitempty"`
|
||||
SoraClientEnabled bool `json:"sora_client_enabled"`
|
||||
CustomMenuItems json.RawMessage `json:"custom_menu_items"`
|
||||
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
||||
Version string `json:"version,omitempty"`
|
||||
RegistrationEnabled bool `json:"registration_enabled"`
|
||||
EmailVerifyEnabled bool `json:"email_verify_enabled"`
|
||||
RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
|
||||
PromoCodeEnabled bool `json:"promo_code_enabled"`
|
||||
PasswordResetEnabled bool `json:"password_reset_enabled"`
|
||||
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
|
||||
TotpEnabled bool `json:"totp_enabled"`
|
||||
TurnstileEnabled bool `json:"turnstile_enabled"`
|
||||
TurnstileSiteKey string `json:"turnstile_site_key,omitempty"`
|
||||
SiteName string `json:"site_name"`
|
||||
SiteLogo string `json:"site_logo,omitempty"`
|
||||
SiteSubtitle string `json:"site_subtitle,omitempty"`
|
||||
APIBaseURL string `json:"api_base_url,omitempty"`
|
||||
ContactInfo string `json:"contact_info,omitempty"`
|
||||
DocURL string `json:"doc_url,omitempty"`
|
||||
HomeContent string `json:"home_content,omitempty"`
|
||||
HideCcsImportButton bool `json:"hide_ccs_import_button"`
|
||||
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
|
||||
PurchaseSubscriptionURL string `json:"purchase_subscription_url,omitempty"`
|
||||
SoraClientEnabled bool `json:"sora_client_enabled"`
|
||||
CustomMenuItems json.RawMessage `json:"custom_menu_items"`
|
||||
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}{
|
||||
RegistrationEnabled: settings.RegistrationEnabled,
|
||||
EmailVerifyEnabled: settings.EmailVerifyEnabled,
|
||||
PromoCodeEnabled: settings.PromoCodeEnabled,
|
||||
PasswordResetEnabled: settings.PasswordResetEnabled,
|
||||
InvitationCodeEnabled: settings.InvitationCodeEnabled,
|
||||
TotpEnabled: settings.TotpEnabled,
|
||||
TurnstileEnabled: settings.TurnstileEnabled,
|
||||
TurnstileSiteKey: settings.TurnstileSiteKey,
|
||||
SiteName: settings.SiteName,
|
||||
SiteLogo: settings.SiteLogo,
|
||||
SiteSubtitle: settings.SiteSubtitle,
|
||||
APIBaseURL: settings.APIBaseURL,
|
||||
ContactInfo: settings.ContactInfo,
|
||||
DocURL: settings.DocURL,
|
||||
HomeContent: settings.HomeContent,
|
||||
HideCcsImportButton: settings.HideCcsImportButton,
|
||||
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
|
||||
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
|
||||
SoraClientEnabled: settings.SoraClientEnabled,
|
||||
CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems),
|
||||
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
||||
Version: s.version,
|
||||
RegistrationEnabled: settings.RegistrationEnabled,
|
||||
EmailVerifyEnabled: settings.EmailVerifyEnabled,
|
||||
RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist,
|
||||
PromoCodeEnabled: settings.PromoCodeEnabled,
|
||||
PasswordResetEnabled: settings.PasswordResetEnabled,
|
||||
InvitationCodeEnabled: settings.InvitationCodeEnabled,
|
||||
TotpEnabled: settings.TotpEnabled,
|
||||
TurnstileEnabled: settings.TurnstileEnabled,
|
||||
TurnstileSiteKey: settings.TurnstileSiteKey,
|
||||
SiteName: settings.SiteName,
|
||||
SiteLogo: settings.SiteLogo,
|
||||
SiteSubtitle: settings.SiteSubtitle,
|
||||
APIBaseURL: settings.APIBaseURL,
|
||||
ContactInfo: settings.ContactInfo,
|
||||
DocURL: settings.DocURL,
|
||||
HomeContent: settings.HomeContent,
|
||||
HideCcsImportButton: settings.HideCcsImportButton,
|
||||
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
|
||||
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
|
||||
SoraClientEnabled: settings.SoraClientEnabled,
|
||||
CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems),
|
||||
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
||||
Version: s.version,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -356,12 +363,25 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
|
||||
if err := s.validateDefaultSubscriptionGroups(ctx, settings.DefaultSubscriptions); err != nil {
|
||||
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[SettingKeyRegistrationEnabled] = strconv.FormatBool(settings.RegistrationEnabled)
|
||||
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[SettingKeyPasswordResetEnabled] = strconv.FormatBool(settings.PasswordResetEnabled)
|
||||
updates[SettingKeyInvitationCodeEnabled] = strconv.FormatBool(settings.InvitationCodeEnabled)
|
||||
@@ -514,6 +534,15 @@ func (s *SettingService) IsEmailVerifyEnabled(ctx context.Context) bool {
|
||||
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 检查是否启用优惠码功能
|
||||
func (s *SettingService) IsPromoCodeEnabled(ctx context.Context) bool {
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyPromoCodeEnabled)
|
||||
@@ -617,20 +646,21 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
|
||||
|
||||
// 初始化默认设置
|
||||
defaults := map[string]string{
|
||||
SettingKeyRegistrationEnabled: "true",
|
||||
SettingKeyEmailVerifyEnabled: "false",
|
||||
SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能
|
||||
SettingKeySiteName: "Sub2API",
|
||||
SettingKeySiteLogo: "",
|
||||
SettingKeyPurchaseSubscriptionEnabled: "false",
|
||||
SettingKeyPurchaseSubscriptionURL: "",
|
||||
SettingKeySoraClientEnabled: "false",
|
||||
SettingKeyCustomMenuItems: "[]",
|
||||
SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency),
|
||||
SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64),
|
||||
SettingKeyDefaultSubscriptions: "[]",
|
||||
SettingKeySMTPPort: "587",
|
||||
SettingKeySMTPUseTLS: "false",
|
||||
SettingKeyRegistrationEnabled: "true",
|
||||
SettingKeyEmailVerifyEnabled: "false",
|
||||
SettingKeyRegistrationEmailSuffixWhitelist: "[]",
|
||||
SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能
|
||||
SettingKeySiteName: "Sub2API",
|
||||
SettingKeySiteLogo: "",
|
||||
SettingKeyPurchaseSubscriptionEnabled: "false",
|
||||
SettingKeyPurchaseSubscriptionURL: "",
|
||||
SettingKeySoraClientEnabled: "false",
|
||||
SettingKeyCustomMenuItems: "[]",
|
||||
SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency),
|
||||
SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64),
|
||||
SettingKeyDefaultSubscriptions: "[]",
|
||||
SettingKeySMTPPort: "587",
|
||||
SettingKeySMTPUseTLS: "false",
|
||||
// Model fallback defaults
|
||||
SettingKeyEnableModelFallback: "false",
|
||||
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 {
|
||||
emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true"
|
||||
result := &SystemSettings{
|
||||
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
|
||||
EmailVerifyEnabled: emailVerifyEnabled,
|
||||
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
|
||||
PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true",
|
||||
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
|
||||
TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
|
||||
SMTPHost: settings[SettingKeySMTPHost],
|
||||
SMTPUsername: settings[SettingKeySMTPUsername],
|
||||
SMTPFrom: settings[SettingKeySMTPFrom],
|
||||
SMTPFromName: settings[SettingKeySMTPFromName],
|
||||
SMTPUseTLS: settings[SettingKeySMTPUseTLS] == "true",
|
||||
SMTPPasswordConfigured: settings[SettingKeySMTPPassword] != "",
|
||||
TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true",
|
||||
TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
|
||||
TurnstileSecretKeyConfigured: settings[SettingKeyTurnstileSecretKey] != "",
|
||||
SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"),
|
||||
SiteLogo: settings[SettingKeySiteLogo],
|
||||
SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"),
|
||||
APIBaseURL: settings[SettingKeyAPIBaseURL],
|
||||
ContactInfo: settings[SettingKeyContactInfo],
|
||||
DocURL: settings[SettingKeyDocURL],
|
||||
HomeContent: settings[SettingKeyHomeContent],
|
||||
HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true",
|
||||
PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
|
||||
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
|
||||
SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true",
|
||||
CustomMenuItems: settings[SettingKeyCustomMenuItems],
|
||||
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
|
||||
EmailVerifyEnabled: emailVerifyEnabled,
|
||||
RegistrationEmailSuffixWhitelist: ParseRegistrationEmailSuffixWhitelist(settings[SettingKeyRegistrationEmailSuffixWhitelist]),
|
||||
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
|
||||
PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true",
|
||||
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
|
||||
TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
|
||||
SMTPHost: settings[SettingKeySMTPHost],
|
||||
SMTPUsername: settings[SettingKeySMTPUsername],
|
||||
SMTPFrom: settings[SettingKeySMTPFrom],
|
||||
SMTPFromName: settings[SettingKeySMTPFromName],
|
||||
SMTPUseTLS: settings[SettingKeySMTPUseTLS] == "true",
|
||||
SMTPPasswordConfigured: settings[SettingKeySMTPPassword] != "",
|
||||
TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true",
|
||||
TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
|
||||
TurnstileSecretKeyConfigured: settings[SettingKeyTurnstileSecretKey] != "",
|
||||
SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"),
|
||||
SiteLogo: settings[SettingKeySiteLogo],
|
||||
SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"),
|
||||
APIBaseURL: settings[SettingKeyAPIBaseURL],
|
||||
ContactInfo: settings[SettingKeyContactInfo],
|
||||
DocURL: settings[SettingKeyDocURL],
|
||||
HomeContent: settings[SettingKeyHomeContent],
|
||||
HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true",
|
||||
PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
|
||||
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
|
||||
SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true",
|
||||
CustomMenuItems: settings[SettingKeyCustomMenuItems],
|
||||
}
|
||||
|
||||
// 解析整数类型
|
||||
|
||||
64
backend/internal/service/setting_service_public_test.go
Normal file
64
backend/internal/service/setting_service_public_test.go
Normal 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)
|
||||
}
|
||||
@@ -172,6 +172,28 @@ func TestSettingService_UpdateSettings_DefaultSubscriptions_RejectsDuplicateGrou
|
||||
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) {
|
||||
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{
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
package service
|
||||
|
||||
type SystemSettings struct {
|
||||
RegistrationEnabled bool
|
||||
EmailVerifyEnabled bool
|
||||
PromoCodeEnabled bool
|
||||
PasswordResetEnabled bool
|
||||
InvitationCodeEnabled bool
|
||||
TotpEnabled bool // TOTP 双因素认证
|
||||
RegistrationEnabled bool
|
||||
EmailVerifyEnabled bool
|
||||
RegistrationEmailSuffixWhitelist []string
|
||||
PromoCodeEnabled bool
|
||||
PasswordResetEnabled bool
|
||||
InvitationCodeEnabled bool
|
||||
TotpEnabled bool // TOTP 双因素认证
|
||||
|
||||
SMTPHost string
|
||||
SMTPPort int
|
||||
@@ -76,22 +77,23 @@ type DefaultSubscriptionSetting struct {
|
||||
}
|
||||
|
||||
type PublicSettings struct {
|
||||
RegistrationEnabled bool
|
||||
EmailVerifyEnabled bool
|
||||
PromoCodeEnabled bool
|
||||
PasswordResetEnabled bool
|
||||
InvitationCodeEnabled bool
|
||||
TotpEnabled bool // TOTP 双因素认证
|
||||
TurnstileEnabled bool
|
||||
TurnstileSiteKey string
|
||||
SiteName string
|
||||
SiteLogo string
|
||||
SiteSubtitle string
|
||||
APIBaseURL string
|
||||
ContactInfo string
|
||||
DocURL string
|
||||
HomeContent string
|
||||
HideCcsImportButton bool
|
||||
RegistrationEnabled bool
|
||||
EmailVerifyEnabled bool
|
||||
RegistrationEmailSuffixWhitelist []string
|
||||
PromoCodeEnabled bool
|
||||
PasswordResetEnabled bool
|
||||
InvitationCodeEnabled bool
|
||||
TotpEnabled bool // TOTP 双因素认证
|
||||
TurnstileEnabled bool
|
||||
TurnstileSiteKey string
|
||||
SiteName string
|
||||
SiteLogo string
|
||||
SiteSubtitle string
|
||||
APIBaseURL string
|
||||
ContactInfo string
|
||||
DocURL string
|
||||
HomeContent string
|
||||
HideCcsImportButton bool
|
||||
|
||||
PurchaseSubscriptionEnabled bool
|
||||
PurchaseSubscriptionURL string
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface SystemSettings {
|
||||
// Registration settings
|
||||
registration_enabled: boolean
|
||||
email_verify_enabled: boolean
|
||||
registration_email_suffix_whitelist: string[]
|
||||
promo_code_enabled: boolean
|
||||
password_reset_enabled: boolean
|
||||
invitation_code_enabled: boolean
|
||||
@@ -86,6 +87,7 @@ export interface SystemSettings {
|
||||
export interface UpdateSettingsRequest {
|
||||
registration_enabled?: boolean
|
||||
email_verify_enabled?: boolean
|
||||
registration_email_suffix_whitelist?: string[]
|
||||
promo_code_enabled?: boolean
|
||||
password_reset_enabled?: boolean
|
||||
invitation_code_enabled?: boolean
|
||||
|
||||
@@ -312,6 +312,9 @@ export default {
|
||||
passwordMinLength: 'Password must be at least 6 characters',
|
||||
loginFailed: 'Login failed. Please check your credentials and 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.',
|
||||
accountCreatedSuccess: 'Account created successfully! Welcome to {siteName}.',
|
||||
reloginRequired: 'Session expired. Please log in again.',
|
||||
@@ -3528,6 +3531,11 @@ export default {
|
||||
enableRegistrationHint: 'Allow new users to register',
|
||||
emailVerification: 'Email Verification',
|
||||
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',
|
||||
promoCodeHint: 'Allow users to use promo codes during registration',
|
||||
invitationCode: 'Invitation Code Registration',
|
||||
|
||||
@@ -312,6 +312,8 @@ export default {
|
||||
passwordMinLength: '密码至少需要 6 个字符',
|
||||
loginFailed: '登录失败,请检查您的凭据后重试。',
|
||||
registrationFailed: '注册失败,请重试。',
|
||||
emailSuffixNotAllowed: '该邮箱域名不在允许注册范围内。',
|
||||
emailSuffixNotAllowedWithAllowed: '该邮箱域名不被允许。可用域名:{suffixes}',
|
||||
loginSuccess: '登录成功!欢迎回来。',
|
||||
accountCreatedSuccess: '账户创建成功!欢迎使用 {siteName}。',
|
||||
reloginRequired: '会话已过期,请重新登录。',
|
||||
@@ -3698,6 +3700,11 @@ export default {
|
||||
enableRegistrationHint: '允许新用户注册',
|
||||
emailVerification: '邮箱验证',
|
||||
emailVerificationHint: '新用户注册时需要验证邮箱',
|
||||
emailSuffixWhitelist: '邮箱域名白名单',
|
||||
emailSuffixWhitelistHint:
|
||||
'仅允许使用指定域名的邮箱注册账号(例如 @qq.com, @gmail.com)',
|
||||
emailSuffixWhitelistPlaceholder: 'example.com',
|
||||
emailSuffixWhitelistInputHint: '留空则不限制',
|
||||
promoCode: '优惠码',
|
||||
promoCodeHint: '允许用户在注册时使用优惠码',
|
||||
invitationCode: '邀请码注册',
|
||||
|
||||
@@ -312,6 +312,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
return {
|
||||
registration_enabled: false,
|
||||
email_verify_enabled: false,
|
||||
registration_email_suffix_whitelist: [],
|
||||
promo_code_enabled: true,
|
||||
password_reset_enabled: false,
|
||||
invitation_code_enabled: false,
|
||||
|
||||
@@ -87,6 +87,7 @@ export interface CustomMenuItem {
|
||||
export interface PublicSettings {
|
||||
registration_enabled: boolean
|
||||
email_verify_enabled: boolean
|
||||
registration_email_suffix_whitelist: string[]
|
||||
promo_code_enabled: boolean
|
||||
password_reset_enabled: boolean
|
||||
invitation_code_enabled: boolean
|
||||
|
||||
47
frontend/src/utils/__tests__/authError.spec.ts
Normal file
47
frontend/src/utils/__tests__/authError.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
77
frontend/src/utils/__tests__/registrationEmailPolicy.spec.ts
Normal file
77
frontend/src/utils/__tests__/registrationEmailPolicy.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
25
frontend/src/utils/authError.ts
Normal file
25
frontend/src/utils/authError.ts
Normal 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
|
||||
}
|
||||
115
frontend/src/utils/registrationEmailPolicy.ts
Normal file
115
frontend/src/utils/registrationEmailPolicy.ts
Normal 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)
|
||||
}
|
||||
@@ -324,6 +324,56 @@
|
||||
<Toggle v-model="form.email_verify_enabled" />
|
||||
</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 -->
|
||||
<div
|
||||
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 { useAppStore } from '@/stores'
|
||||
import { useAdminSettingsStore } from '@/stores/adminSettings'
|
||||
import {
|
||||
isRegistrationEmailSuffixDomainValid,
|
||||
normalizeRegistrationEmailSuffixDomain,
|
||||
normalizeRegistrationEmailSuffixDomains,
|
||||
parseRegistrationEmailSuffixWhitelistInput
|
||||
} from '@/utils/registrationEmailPolicy'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
@@ -1375,6 +1431,9 @@ const saving = ref(false)
|
||||
const testingSmtp = ref(false)
|
||||
const sendingTestEmail = ref(false)
|
||||
const testEmailAddress = ref('')
|
||||
const logoError = ref('')
|
||||
const registrationEmailSuffixWhitelistTags = ref<string[]>([])
|
||||
const registrationEmailSuffixWhitelistDraft = ref('')
|
||||
|
||||
// Admin API Key 状态
|
||||
const adminApiKeyLoading = ref(true)
|
||||
@@ -1414,6 +1473,7 @@ type SettingsForm = SystemSettings & {
|
||||
const form = reactive<SettingsForm>({
|
||||
registration_enabled: true,
|
||||
email_verify_enabled: false,
|
||||
registration_email_suffix_whitelist: [],
|
||||
promo_code_enabled: true,
|
||||
invitation_code_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
|
||||
const linuxdoRedirectUrlSuggestion = computed(() => {
|
||||
if (typeof window === 'undefined') return ''
|
||||
@@ -1546,6 +1674,10 @@ async function loadSettings() {
|
||||
validity_days: item.validity_days
|
||||
}))
|
||||
: []
|
||||
registrationEmailSuffixWhitelistTags.value = normalizeRegistrationEmailSuffixDomains(
|
||||
settings.registration_email_suffix_whitelist
|
||||
)
|
||||
registrationEmailSuffixWhitelistDraft.value = ''
|
||||
form.smtp_password = ''
|
||||
form.turnstile_secret_key = ''
|
||||
form.linuxdo_connect_client_secret = ''
|
||||
@@ -1615,6 +1747,9 @@ async function saveSettings() {
|
||||
const payload: UpdateSettingsRequest = {
|
||||
registration_enabled: form.registration_enabled,
|
||||
email_verify_enabled: form.email_verify_enabled,
|
||||
registration_email_suffix_whitelist: registrationEmailSuffixWhitelistTags.value.map(
|
||||
(suffix) => `@${suffix}`
|
||||
),
|
||||
promo_code_enabled: form.promo_code_enabled,
|
||||
invitation_code_enabled: form.invitation_code_enabled,
|
||||
password_reset_enabled: form.password_reset_enabled,
|
||||
@@ -1660,6 +1795,10 @@ async function saveSettings() {
|
||||
}
|
||||
const updated = await adminAPI.settings.updateSettings(payload)
|
||||
Object.assign(form, updated)
|
||||
registrationEmailSuffixWhitelistTags.value = normalizeRegistrationEmailSuffixDomains(
|
||||
updated.registration_email_suffix_whitelist
|
||||
)
|
||||
registrationEmailSuffixWhitelistDraft.value = ''
|
||||
form.smtp_password = ''
|
||||
form.turnstile_secret_key = ''
|
||||
form.linuxdo_connect_client_secret = ''
|
||||
|
||||
@@ -177,8 +177,13 @@ import Icon from '@/components/icons/Icon.vue'
|
||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
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 ====================
|
||||
|
||||
@@ -208,6 +213,7 @@ const hasRegisterData = ref<boolean>(false)
|
||||
const turnstileEnabled = ref<boolean>(false)
|
||||
const turnstileSiteKey = ref<string>('')
|
||||
const siteName = ref<string>('Sub2API')
|
||||
const registrationEmailSuffixWhitelist = ref<string[]>([])
|
||||
|
||||
// Turnstile for resend
|
||||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||||
@@ -244,6 +250,9 @@ onMounted(async () => {
|
||||
turnstileEnabled.value = settings.turnstile_enabled
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||
siteName.value = settings.site_name || 'Sub2API'
|
||||
registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist(
|
||||
settings.registration_email_suffix_whitelist || []
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Failed to load public settings:', error)
|
||||
}
|
||||
@@ -306,6 +315,12 @@ async function sendCode(): Promise<void> {
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
if (!isRegistrationEmailSuffixAllowed(email.value, registrationEmailSuffixWhitelist.value)) {
|
||||
errorMessage.value = buildEmailSuffixNotAllowedMessage()
|
||||
appStore.showError(errorMessage.value)
|
||||
return
|
||||
}
|
||||
|
||||
const response = await sendVerifyCode({
|
||||
email: email.value,
|
||||
// 优先使用重发时新获取的 token(因为初始 token 可能已被使用)
|
||||
@@ -320,15 +335,9 @@ async function sendCode(): Promise<void> {
|
||||
showResendTurnstile.value = false
|
||||
resendTurnstileToken.value = ''
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string; response?: { data?: { detail?: string } } }
|
||||
|
||||
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.'
|
||||
}
|
||||
errorMessage.value = buildAuthErrorMessage(error, {
|
||||
fallback: 'Failed to send verification code. Please try again.'
|
||||
})
|
||||
|
||||
appStore.showError(errorMessage.value)
|
||||
} finally {
|
||||
@@ -380,6 +389,12 @@ async function handleVerify(): Promise<void> {
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
if (!isRegistrationEmailSuffixAllowed(email.value, registrationEmailSuffixWhitelist.value)) {
|
||||
errorMessage.value = buildEmailSuffixNotAllowedMessage()
|
||||
appStore.showError(errorMessage.value)
|
||||
return
|
||||
}
|
||||
|
||||
// Register with verification code
|
||||
await authStore.register({
|
||||
email: email.value,
|
||||
@@ -399,15 +414,9 @@ async function handleVerify(): Promise<void> {
|
||||
// Redirect to dashboard
|
||||
await router.push('/dashboard')
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string; response?: { data?: { detail?: string } } }
|
||||
|
||||
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.'
|
||||
}
|
||||
errorMessage.value = buildAuthErrorMessage(error, {
|
||||
fallback: 'Verification failed. Please try again.'
|
||||
})
|
||||
|
||||
appStore.showError(errorMessage.value)
|
||||
} finally {
|
||||
@@ -422,6 +431,19 @@ function handleBack(): void {
|
||||
// Go back to registration
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -293,8 +293,13 @@ import Icon from '@/components/icons/Icon.vue'
|
||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
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 ====================
|
||||
|
||||
@@ -319,6 +324,7 @@ const turnstileEnabled = ref<boolean>(false)
|
||||
const turnstileSiteKey = ref<string>('')
|
||||
const siteName = ref<string>('Sub2API')
|
||||
const linuxdoOAuthEnabled = ref<boolean>(false)
|
||||
const registrationEmailSuffixWhitelist = ref<string[]>([])
|
||||
|
||||
// Turnstile
|
||||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||||
@@ -370,6 +376,9 @@ onMounted(async () => {
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||
siteName.value = settings.site_name || 'Sub2API'
|
||||
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
|
||||
if (promoCodeEnabled.value) {
|
||||
@@ -557,6 +566,19 @@ 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: normalizedWhitelist.join(separator)
|
||||
})
|
||||
}
|
||||
|
||||
function validateForm(): boolean {
|
||||
// Reset errors
|
||||
errors.email = ''
|
||||
@@ -573,6 +595,11 @@ function validateForm(): boolean {
|
||||
} else if (!validateEmail(formData.email)) {
|
||||
errors.email = t('auth.invalidEmail')
|
||||
isValid = false
|
||||
} else if (
|
||||
!isRegistrationEmailSuffixAllowed(formData.email, registrationEmailSuffixWhitelist.value)
|
||||
) {
|
||||
errors.email = buildEmailSuffixNotAllowedMessage()
|
||||
isValid = false
|
||||
}
|
||||
|
||||
// Password validation
|
||||
@@ -694,15 +721,9 @@ async function handleRegister(): Promise<void> {
|
||||
}
|
||||
|
||||
// Handle registration error
|
||||
const err = error as { message?: string; response?: { data?: { detail?: string } } }
|
||||
|
||||
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')
|
||||
}
|
||||
errorMessage.value = buildAuthErrorMessage(error, {
|
||||
fallback: t('auth.registrationFailed')
|
||||
})
|
||||
|
||||
// Also show error toast
|
||||
appStore.showError(errorMessage.value)
|
||||
|
||||
Reference in New Issue
Block a user