feat(registration): 支持邮箱白名单后缀通配符
This commit is contained in:
parent
16793d3af0
commit
a5b9b68b76
@ -26,12 +26,17 @@ func IsRegistrationEmailSuffixAllowed(email string, whitelist []string) bool {
|
|||||||
if len(whitelist) == 0 {
|
if len(whitelist) == 0 {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
suffix := RegistrationEmailSuffix(email)
|
_, domain, ok := splitEmailForPolicy(email)
|
||||||
if suffix == "" {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
suffix := "@" + domain
|
||||||
for _, allowed := range whitelist {
|
for _, allowed := range whitelist {
|
||||||
if suffix == allowed {
|
allowed = strings.ToLower(strings.TrimSpace(allowed))
|
||||||
|
if strings.HasPrefix(allowed, "@") && suffix == allowed {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(allowed, "*.") && registrationEmailDomainMatchesWildcard(domain, allowed) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -98,6 +103,14 @@ func normalizeRegistrationEmailSuffix(raw string) (string, error) {
|
|||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(value, "*.") {
|
||||||
|
domain := strings.TrimPrefix(value, "*.")
|
||||||
|
if !isValidRegistrationEmailDomain(domain) {
|
||||||
|
return "", fmt.Errorf("invalid email suffix: %q", raw)
|
||||||
|
}
|
||||||
|
return "*." + domain, nil
|
||||||
|
}
|
||||||
|
|
||||||
domain := value
|
domain := value
|
||||||
if strings.Contains(value, "@") {
|
if strings.Contains(value, "@") {
|
||||||
if !strings.HasPrefix(value, "@") || strings.Count(value, "@") != 1 {
|
if !strings.HasPrefix(value, "@") || strings.Count(value, "@") != 1 {
|
||||||
@ -106,13 +119,27 @@ func normalizeRegistrationEmailSuffix(raw string) (string, error) {
|
|||||||
domain = strings.TrimPrefix(value, "@")
|
domain = strings.TrimPrefix(value, "@")
|
||||||
}
|
}
|
||||||
|
|
||||||
if domain == "" || strings.Contains(domain, "@") || !registrationEmailDomainPattern.MatchString(domain) {
|
if !isValidRegistrationEmailDomain(domain) {
|
||||||
return "", fmt.Errorf("invalid email suffix: %q", raw)
|
return "", fmt.Errorf("invalid email suffix: %q", raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
return "@" + domain, nil
|
return "@" + domain, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isValidRegistrationEmailDomain(domain string) bool {
|
||||||
|
return domain != "" &&
|
||||||
|
!strings.Contains(domain, "@") &&
|
||||||
|
registrationEmailDomainPattern.MatchString(domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
func registrationEmailDomainMatchesWildcard(domain string, allowed string) bool {
|
||||||
|
base := strings.TrimPrefix(allowed, "*.")
|
||||||
|
if !isValidRegistrationEmailDomain(base) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return domain == base || strings.HasSuffix(domain, "."+base)
|
||||||
|
}
|
||||||
|
|
||||||
func splitEmailForPolicy(raw string) (local string, domain string, ok bool) {
|
func splitEmailForPolicy(raw string) (local string, domain string, ok bool) {
|
||||||
email := strings.ToLower(strings.TrimSpace(raw))
|
email := strings.ToLower(strings.TrimSpace(raw))
|
||||||
local, domain, found := strings.Cut(email, "@")
|
local, domain, found := strings.Cut(email, "@")
|
||||||
|
|||||||
@ -9,23 +9,36 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestNormalizeRegistrationEmailSuffixWhitelist(t *testing.T) {
|
func TestNormalizeRegistrationEmailSuffixWhitelist(t *testing.T) {
|
||||||
got, err := NormalizeRegistrationEmailSuffixWhitelist([]string{"example.com", "@EXAMPLE.COM", " @foo.bar "})
|
got, err := NormalizeRegistrationEmailSuffixWhitelist([]string{"example.com", "@EXAMPLE.COM", " @foo.bar ", "*.EDU.CN"})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, []string{"@example.com", "@foo.bar"}, got)
|
require.Equal(t, []string{"@example.com", "@foo.bar", "*.edu.cn"}, got)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizeRegistrationEmailSuffixWhitelist_Invalid(t *testing.T) {
|
func TestNormalizeRegistrationEmailSuffixWhitelist_Invalid(t *testing.T) {
|
||||||
_, err := NormalizeRegistrationEmailSuffixWhitelist([]string{"@invalid_domain"})
|
for _, item := range []string{"@invalid_domain", "*.", "*", "*.@", "*.foo"} {
|
||||||
require.Error(t, err)
|
t.Run(item, func(t *testing.T) {
|
||||||
|
_, err := NormalizeRegistrationEmailSuffixWhitelist([]string{item})
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseRegistrationEmailSuffixWhitelist(t *testing.T) {
|
func TestParseRegistrationEmailSuffixWhitelist(t *testing.T) {
|
||||||
got := ParseRegistrationEmailSuffixWhitelist(`["example.com","@foo.bar","@invalid_domain"]`)
|
got := ParseRegistrationEmailSuffixWhitelist(`["example.com","@foo.bar","*.EDU.CN","@invalid_domain","*.foo"]`)
|
||||||
require.Equal(t, []string{"@example.com", "@foo.bar"}, got)
|
require.Equal(t, []string{"@example.com", "@foo.bar", "*.edu.cn"}, got)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIsRegistrationEmailSuffixAllowed(t *testing.T) {
|
func TestIsRegistrationEmailSuffixAllowed(t *testing.T) {
|
||||||
require.True(t, IsRegistrationEmailSuffixAllowed("user@example.com", []string{"@example.com"}))
|
require.True(t, IsRegistrationEmailSuffixAllowed("user@example.com", []string{"@example.com"}))
|
||||||
require.False(t, IsRegistrationEmailSuffixAllowed("user@sub.example.com", []string{"@example.com"}))
|
require.False(t, IsRegistrationEmailSuffixAllowed("user@sub.example.com", []string{"@example.com"}))
|
||||||
|
require.True(t, IsRegistrationEmailSuffixAllowed("user@qq.com", []string{"@qq.com"}))
|
||||||
|
require.False(t, IsRegistrationEmailSuffixAllowed("user@sub.qq.com", []string{"@qq.com"}))
|
||||||
|
require.True(t, IsRegistrationEmailSuffixAllowed("student@cs.edu.cn", []string{"*.edu.cn"}))
|
||||||
|
require.True(t, IsRegistrationEmailSuffixAllowed("student@edu.cn", []string{"*.edu.cn"}))
|
||||||
|
require.False(t, IsRegistrationEmailSuffixAllowed("student@foo.cn", []string{"*.edu.cn"}))
|
||||||
|
require.True(t, IsRegistrationEmailSuffixAllowed("user@a.com", []string{"@a.com", "*.b.cn"}))
|
||||||
|
require.True(t, IsRegistrationEmailSuffixAllowed("user@school.b.cn", []string{"@a.com", "*.b.cn"}))
|
||||||
|
require.True(t, IsRegistrationEmailSuffixAllowed("user@b.cn", []string{"@a.com", "*.b.cn"}))
|
||||||
|
require.False(t, IsRegistrationEmailSuffixAllowed("user@c.cn", []string{"@a.com", "*.b.cn"}))
|
||||||
require.True(t, IsRegistrationEmailSuffixAllowed("user@any.com", []string{}))
|
require.True(t, IsRegistrationEmailSuffixAllowed("user@any.com", []string{}))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -53,14 +53,14 @@ func TestSettingService_GetPublicSettings_ExposesRegistrationEmailSuffixWhitelis
|
|||||||
values: map[string]string{
|
values: map[string]string{
|
||||||
SettingKeyRegistrationEnabled: "true",
|
SettingKeyRegistrationEnabled: "true",
|
||||||
SettingKeyEmailVerifyEnabled: "true",
|
SettingKeyEmailVerifyEnabled: "true",
|
||||||
SettingKeyRegistrationEmailSuffixWhitelist: `["@EXAMPLE.com"," @foo.bar ","@invalid_domain",""]`,
|
SettingKeyRegistrationEmailSuffixWhitelist: `["@EXAMPLE.com"," @foo.bar ","*.EDU.CN","@invalid_domain",""]`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
svc := NewSettingService(repo, &config.Config{})
|
svc := NewSettingService(repo, &config.Config{})
|
||||||
|
|
||||||
settings, err := svc.GetPublicSettings(context.Background())
|
settings, err := svc.GetPublicSettings(context.Background())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, []string{"@example.com", "@foo.bar"}, settings.RegistrationEmailSuffixWhitelist)
|
require.Equal(t, []string{"@example.com", "@foo.bar", "*.edu.cn"}, settings.RegistrationEmailSuffixWhitelist)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSettingService_GetPublicSettings_ExposesTablePreferences(t *testing.T) {
|
func TestSettingService_GetPublicSettings_ExposesTablePreferences(t *testing.T) {
|
||||||
|
|||||||
@ -213,10 +213,10 @@ func TestSettingService_UpdateSettings_RegistrationEmailSuffixWhitelist_Normaliz
|
|||||||
svc := NewSettingService(repo, &config.Config{})
|
svc := NewSettingService(repo, &config.Config{})
|
||||||
|
|
||||||
err := svc.UpdateSettings(context.Background(), &SystemSettings{
|
err := svc.UpdateSettings(context.Background(), &SystemSettings{
|
||||||
RegistrationEmailSuffixWhitelist: []string{"example.com", "@EXAMPLE.com", " @foo.bar "},
|
RegistrationEmailSuffixWhitelist: []string{"example.com", "@EXAMPLE.com", " @foo.bar ", "*.EDU.CN"},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, `["@example.com","@foo.bar"]`, repo.updates[SettingKeyRegistrationEmailSuffixWhitelist])
|
require.Equal(t, `["@example.com","@foo.bar","*.edu.cn"]`, repo.updates[SettingKeyRegistrationEmailSuffixWhitelist])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSettingService_UpdateSettings_RegistrationEmailSuffixWhitelist_Invalid(t *testing.T) {
|
func TestSettingService_UpdateSettings_RegistrationEmailSuffixWhitelist_Invalid(t *testing.T) {
|
||||||
|
|||||||
@ -423,6 +423,7 @@ export default {
|
|||||||
emailSuffixNotAllowed: 'This email domain is not allowed for registration.',
|
emailSuffixNotAllowed: 'This email domain is not allowed for registration.',
|
||||||
emailSuffixNotAllowedWithAllowed:
|
emailSuffixNotAllowedWithAllowed:
|
||||||
'This email domain is not allowed. Allowed domains: {suffixes}',
|
'This email domain is not allowed. Allowed domains: {suffixes}',
|
||||||
|
emailSuffixAllowedMore: 'and {count} more',
|
||||||
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.',
|
||||||
@ -5282,9 +5283,9 @@ export default {
|
|||||||
emailVerificationHint: 'Require email verification for new registrations',
|
emailVerificationHint: 'Require email verification for new registrations',
|
||||||
emailSuffixWhitelist: 'Email Domain Whitelist',
|
emailSuffixWhitelist: 'Email Domain Whitelist',
|
||||||
emailSuffixWhitelistHint:
|
emailSuffixWhitelistHint:
|
||||||
"Only email addresses from the specified domains can register (for example, {'@'}qq.com, {'@'}gmail.com)",
|
"Only email addresses from the specified domains can register (for example, {'@'}qq.com, {'@'}gmail.com, *.edu.cn)",
|
||||||
emailSuffixWhitelistPlaceholder: 'example.com',
|
emailSuffixWhitelistPlaceholder: '@example.com, *.edu.cn',
|
||||||
emailSuffixWhitelistInputHint: 'Leave empty for no restriction',
|
emailSuffixWhitelistInputHint: 'Leave empty for no restriction. Use *.edu.cn to match edu.cn and its subdomains.',
|
||||||
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',
|
||||||
|
|||||||
@ -422,6 +422,7 @@ export default {
|
|||||||
registrationFailed: '注册失败,请重试。',
|
registrationFailed: '注册失败,请重试。',
|
||||||
emailSuffixNotAllowed: '该邮箱域名不在允许注册范围内。',
|
emailSuffixNotAllowed: '该邮箱域名不在允许注册范围内。',
|
||||||
emailSuffixNotAllowedWithAllowed: '该邮箱域名不被允许。可用域名:{suffixes}',
|
emailSuffixNotAllowedWithAllowed: '该邮箱域名不被允许。可用域名:{suffixes}',
|
||||||
|
emailSuffixAllowedMore: '等 {count} 项',
|
||||||
loginSuccess: '登录成功!欢迎回来。',
|
loginSuccess: '登录成功!欢迎回来。',
|
||||||
accountCreatedSuccess: '账户创建成功!欢迎使用 {siteName}。',
|
accountCreatedSuccess: '账户创建成功!欢迎使用 {siteName}。',
|
||||||
reloginRequired: '会话已过期,请重新登录。',
|
reloginRequired: '会话已过期,请重新登录。',
|
||||||
@ -5445,9 +5446,9 @@ export default {
|
|||||||
emailVerificationHint: '新用户注册时需要验证邮箱',
|
emailVerificationHint: '新用户注册时需要验证邮箱',
|
||||||
emailSuffixWhitelist: '邮箱域名白名单',
|
emailSuffixWhitelist: '邮箱域名白名单',
|
||||||
emailSuffixWhitelistHint:
|
emailSuffixWhitelistHint:
|
||||||
"仅允许使用指定域名的邮箱注册账号(例如 {'@'}qq.com, {'@'}gmail.com)",
|
"仅允许使用指定域名的邮箱注册账号(例如 {'@'}qq.com, {'@'}gmail.com, *.edu.cn)",
|
||||||
emailSuffixWhitelistPlaceholder: 'example.com',
|
emailSuffixWhitelistPlaceholder: '@example.com, *.edu.cn',
|
||||||
emailSuffixWhitelistInputHint: '留空则不限制',
|
emailSuffixWhitelistInputHint: '留空则不限制。使用 *.edu.cn 可匹配 edu.cn 及其子域名。',
|
||||||
promoCode: '优惠码',
|
promoCode: '优惠码',
|
||||||
promoCodeHint: '允许用户在注册时使用优惠码',
|
promoCodeHint: '允许用户在注册时使用优惠码',
|
||||||
invitationCode: '邀请码注册',
|
invitationCode: '邀请码注册',
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import {
|
import {
|
||||||
|
formatRegistrationEmailSuffixWhitelistForMessage,
|
||||||
isRegistrationEmailSuffixAllowed,
|
isRegistrationEmailSuffixAllowed,
|
||||||
isRegistrationEmailSuffixDomainValid,
|
isRegistrationEmailSuffixDomainValid,
|
||||||
normalizeRegistrationEmailSuffixDomain,
|
normalizeRegistrationEmailSuffixDomain,
|
||||||
@ -11,6 +12,7 @@ import {
|
|||||||
describe('registrationEmailPolicy utils', () => {
|
describe('registrationEmailPolicy utils', () => {
|
||||||
it('normalizeRegistrationEmailSuffixDomain lowercases, strips @, and ignores invalid chars', () => {
|
it('normalizeRegistrationEmailSuffixDomain lowercases, strips @, and ignores invalid chars', () => {
|
||||||
expect(normalizeRegistrationEmailSuffixDomain(' @Exa!mple.COM ')).toBe('example.com')
|
expect(normalizeRegistrationEmailSuffixDomain(' @Exa!mple.COM ')).toBe('example.com')
|
||||||
|
expect(normalizeRegistrationEmailSuffixDomain(' *.EDU!.CN ')).toBe('*.edu.cn')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('normalizeRegistrationEmailSuffixDomains deduplicates normalized domains', () => {
|
it('normalizeRegistrationEmailSuffixDomains deduplicates normalized domains', () => {
|
||||||
@ -22,14 +24,20 @@ describe('registrationEmailPolicy utils', () => {
|
|||||||
'-invalid.com',
|
'-invalid.com',
|
||||||
'foo..bar.com',
|
'foo..bar.com',
|
||||||
' @foo.bar ',
|
' @foo.bar ',
|
||||||
'@foo.bar'
|
'@foo.bar',
|
||||||
|
'*.EDU.CN',
|
||||||
|
'*.edu.cn'
|
||||||
])
|
])
|
||||||
).toEqual(['example.com', 'foo.bar'])
|
).toEqual(['example.com', 'foo.bar', '*.edu.cn'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('parseRegistrationEmailSuffixWhitelistInput supports separators and deduplicates', () => {
|
it('parseRegistrationEmailSuffixWhitelistInput supports separators and deduplicates', () => {
|
||||||
const input = '\n @example.com,example.com,@foo.bar\t@FOO.bar '
|
const input = '\n @example.com,example.com,@foo.bar\t@FOO.bar *.EDU.CN '
|
||||||
expect(parseRegistrationEmailSuffixWhitelistInput(input)).toEqual(['example.com', 'foo.bar'])
|
expect(parseRegistrationEmailSuffixWhitelistInput(input)).toEqual([
|
||||||
|
'example.com',
|
||||||
|
'foo.bar',
|
||||||
|
'*.edu.cn'
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('parseRegistrationEmailSuffixWhitelistInput drops tokens containing invalid chars', () => {
|
it('parseRegistrationEmailSuffixWhitelistInput drops tokens containing invalid chars', () => {
|
||||||
@ -38,7 +46,7 @@ describe('registrationEmailPolicy utils', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('parseRegistrationEmailSuffixWhitelistInput drops structurally invalid domains', () => {
|
it('parseRegistrationEmailSuffixWhitelistInput drops structurally invalid domains', () => {
|
||||||
const input = '@-bad.com, @foo..bar.com, @foo.bar, @xn--ok.com'
|
const input = '@-bad.com, @foo..bar.com, @foo.bar, @xn--ok.com, *., *, *.@, *.foo'
|
||||||
expect(parseRegistrationEmailSuffixWhitelistInput(input)).toEqual(['foo.bar', 'xn--ok.com'])
|
expect(parseRegistrationEmailSuffixWhitelistInput(input)).toEqual(['foo.bar', 'xn--ok.com'])
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -53,17 +61,22 @@ describe('registrationEmailPolicy utils', () => {
|
|||||||
'foo.bar',
|
'foo.bar',
|
||||||
'',
|
'',
|
||||||
'-invalid.com',
|
'-invalid.com',
|
||||||
' @foo.bar '
|
' @foo.bar ',
|
||||||
|
'*.EDU.CN'
|
||||||
])
|
])
|
||||||
).toEqual(['@example.com', '@foo.bar'])
|
).toEqual(['@example.com', '@foo.bar', '*.edu.cn'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('isRegistrationEmailSuffixDomainValid matches backend-compatible domain rules', () => {
|
it('isRegistrationEmailSuffixDomainValid matches backend-compatible domain rules', () => {
|
||||||
expect(isRegistrationEmailSuffixDomainValid('example.com')).toBe(true)
|
expect(isRegistrationEmailSuffixDomainValid('example.com')).toBe(true)
|
||||||
expect(isRegistrationEmailSuffixDomainValid('foo-bar.example.com')).toBe(true)
|
expect(isRegistrationEmailSuffixDomainValid('foo-bar.example.com')).toBe(true)
|
||||||
|
expect(isRegistrationEmailSuffixDomainValid('*.edu.cn')).toBe(true)
|
||||||
expect(isRegistrationEmailSuffixDomainValid('-bad.com')).toBe(false)
|
expect(isRegistrationEmailSuffixDomainValid('-bad.com')).toBe(false)
|
||||||
expect(isRegistrationEmailSuffixDomainValid('foo..bar.com')).toBe(false)
|
expect(isRegistrationEmailSuffixDomainValid('foo..bar.com')).toBe(false)
|
||||||
expect(isRegistrationEmailSuffixDomainValid('localhost')).toBe(false)
|
expect(isRegistrationEmailSuffixDomainValid('localhost')).toBe(false)
|
||||||
|
expect(isRegistrationEmailSuffixDomainValid('*.foo')).toBe(false)
|
||||||
|
expect(isRegistrationEmailSuffixDomainValid('*')).toBe(false)
|
||||||
|
expect(isRegistrationEmailSuffixDomainValid('*.@')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('isRegistrationEmailSuffixAllowed allows any email when whitelist is empty', () => {
|
it('isRegistrationEmailSuffixAllowed allows any email when whitelist is empty', () => {
|
||||||
@ -73,5 +86,36 @@ describe('registrationEmailPolicy utils', () => {
|
|||||||
it('isRegistrationEmailSuffixAllowed applies exact suffix matching', () => {
|
it('isRegistrationEmailSuffixAllowed applies exact suffix matching', () => {
|
||||||
expect(isRegistrationEmailSuffixAllowed('user@example.com', ['@example.com'])).toBe(true)
|
expect(isRegistrationEmailSuffixAllowed('user@example.com', ['@example.com'])).toBe(true)
|
||||||
expect(isRegistrationEmailSuffixAllowed('user@sub.example.com', ['@example.com'])).toBe(false)
|
expect(isRegistrationEmailSuffixAllowed('user@sub.example.com', ['@example.com'])).toBe(false)
|
||||||
|
expect(isRegistrationEmailSuffixAllowed('user@qq.com', ['@qq.com'])).toBe(true)
|
||||||
|
expect(isRegistrationEmailSuffixAllowed('user@sub.qq.com', ['@qq.com'])).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isRegistrationEmailSuffixAllowed applies wildcard suffix matching', () => {
|
||||||
|
expect(isRegistrationEmailSuffixAllowed('student@cs.edu.cn', ['*.edu.cn'])).toBe(true)
|
||||||
|
expect(isRegistrationEmailSuffixAllowed('student@edu.cn', ['*.edu.cn'])).toBe(true)
|
||||||
|
expect(isRegistrationEmailSuffixAllowed('student@foo.cn', ['*.edu.cn'])).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isRegistrationEmailSuffixAllowed supports mixed exact and wildcard entries', () => {
|
||||||
|
const whitelist = ['@a.com', '*.b.cn']
|
||||||
|
expect(isRegistrationEmailSuffixAllowed('user@a.com', whitelist)).toBe(true)
|
||||||
|
expect(isRegistrationEmailSuffixAllowed('user@school.b.cn', whitelist)).toBe(true)
|
||||||
|
expect(isRegistrationEmailSuffixAllowed('user@b.cn', whitelist)).toBe(true)
|
||||||
|
expect(isRegistrationEmailSuffixAllowed('user@c.cn', whitelist)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formatRegistrationEmailSuffixWhitelistForMessage lists up to five entries', () => {
|
||||||
|
expect(
|
||||||
|
formatRegistrationEmailSuffixWhitelistForMessage(
|
||||||
|
['@a.com', '@b.com', '@c.com', '@d.com', '@e.com'],
|
||||||
|
{ separator: ', ', more: (count) => `and ${count} more` }
|
||||||
|
)
|
||||||
|
).toBe('@a.com, @b.com, @c.com, @d.com, @e.com')
|
||||||
|
expect(
|
||||||
|
formatRegistrationEmailSuffixWhitelistForMessage(
|
||||||
|
['@a.com', '@b.com', '@c.com', '@d.com', '@e.com', '*.edu.cn', '@f.com'],
|
||||||
|
{ separator: ', ', more: (count) => `and ${count} more` }
|
||||||
|
)
|
||||||
|
).toBe('@a.com, @b.com, @c.com, @d.com, @e.com, and 2 more')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -2,19 +2,21 @@ const EMAIL_SUFFIX_TOKEN_SPLIT_RE = /[\s,,]+/
|
|||||||
const EMAIL_SUFFIX_INVALID_CHAR_RE = /[^a-z0-9.-]/g
|
const EMAIL_SUFFIX_INVALID_CHAR_RE = /[^a-z0-9.-]/g
|
||||||
const EMAIL_SUFFIX_INVALID_CHAR_CHECK_RE = /[^a-z0-9.-]/
|
const EMAIL_SUFFIX_INVALID_CHAR_CHECK_RE = /[^a-z0-9.-]/
|
||||||
const EMAIL_SUFFIX_PREFIX_RE = /^@+/
|
const EMAIL_SUFFIX_PREFIX_RE = /^@+/
|
||||||
|
const EMAIL_SUFFIX_WILDCARD_PREFIX = '*.'
|
||||||
|
const EMAIL_SUFFIX_MESSAGE_VISIBLE_LIMIT = 5
|
||||||
const EMAIL_SUFFIX_DOMAIN_PATTERN =
|
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])?)+$/
|
/^[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.
|
// normalizeRegistrationEmailSuffixDomain converts raw input into a canonical domain token.
|
||||||
// It removes leading "@", lowercases input, and strips all invalid characters.
|
// Exact domains are returned without "@"; wildcard domains keep the "*." prefix.
|
||||||
export function normalizeRegistrationEmailSuffixDomain(raw: string): string {
|
export function normalizeRegistrationEmailSuffixDomain(raw: string): string {
|
||||||
let value = String(raw || '').trim().toLowerCase()
|
let value = String(raw || '').trim().toLowerCase()
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
value = value.replace(EMAIL_SUFFIX_PREFIX_RE, '')
|
value = value.replace(EMAIL_SUFFIX_PREFIX_RE, '')
|
||||||
value = value.replace(EMAIL_SUFFIX_INVALID_CHAR_RE, '')
|
return normalizeRegistrationEmailSuffixToken(value, false)
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeRegistrationEmailSuffixDomains(
|
export function normalizeRegistrationEmailSuffixDomains(
|
||||||
@ -60,7 +62,7 @@ export function parseRegistrationEmailSuffixWhitelistInput(input: string): strin
|
|||||||
export function normalizeRegistrationEmailSuffixWhitelist(
|
export function normalizeRegistrationEmailSuffixWhitelist(
|
||||||
items: string[] | null | undefined
|
items: string[] | null | undefined
|
||||||
): string[] {
|
): string[] {
|
||||||
return normalizeRegistrationEmailSuffixDomains(items).map((domain) => `@${domain}`)
|
return normalizeRegistrationEmailSuffixDomains(items).map(toCanonicalRegistrationEmailSuffix)
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractRegistrationEmailDomain(email: string): string {
|
function extractRegistrationEmailDomain(email: string): string {
|
||||||
@ -91,7 +93,32 @@ export function isRegistrationEmailSuffixAllowed(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
const emailSuffix = `@${emailDomain}`
|
const emailSuffix = `@${emailDomain}`
|
||||||
return normalizedWhitelist.includes(emailSuffix)
|
return normalizedWhitelist.some((allowed) => {
|
||||||
|
if (allowed.startsWith('@')) {
|
||||||
|
return allowed === emailSuffix
|
||||||
|
}
|
||||||
|
if (allowed.startsWith(EMAIL_SUFFIX_WILDCARD_PREFIX)) {
|
||||||
|
const base = allowed.slice(EMAIL_SUFFIX_WILDCARD_PREFIX.length)
|
||||||
|
return emailDomain === base || emailDomain.endsWith(`.${base}`)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRegistrationEmailSuffixWhitelistForMessage(
|
||||||
|
whitelist: string[] | null | undefined,
|
||||||
|
options: {
|
||||||
|
separator: string
|
||||||
|
more: (count: number) => string
|
||||||
|
}
|
||||||
|
): string {
|
||||||
|
const normalizedWhitelist = normalizeRegistrationEmailSuffixWhitelist(whitelist)
|
||||||
|
const visible = normalizedWhitelist.slice(0, EMAIL_SUFFIX_MESSAGE_VISIBLE_LIMIT)
|
||||||
|
const hiddenCount = normalizedWhitelist.length - visible.length
|
||||||
|
if (hiddenCount > 0) {
|
||||||
|
visible.push(options.more(hiddenCount))
|
||||||
|
}
|
||||||
|
return visible.join(options.separator)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pasted domains should be strict: any invalid character drops the whole token.
|
// Pasted domains should be strict: any invalid character drops the whole token.
|
||||||
@ -101,15 +128,38 @@ function normalizeRegistrationEmailSuffixDomainStrict(raw: string): string {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
value = value.replace(EMAIL_SUFFIX_PREFIX_RE, '')
|
value = value.replace(EMAIL_SUFFIX_PREFIX_RE, '')
|
||||||
if (!value || EMAIL_SUFFIX_INVALID_CHAR_CHECK_RE.test(value)) {
|
return normalizeRegistrationEmailSuffixToken(value, true)
|
||||||
return ''
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isRegistrationEmailSuffixDomainValid(domain: string): boolean {
|
export function isRegistrationEmailSuffixDomainValid(domain: string): boolean {
|
||||||
if (!domain) {
|
if (!domain) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return EMAIL_SUFFIX_DOMAIN_PATTERN.test(domain)
|
if (domain.startsWith(EMAIL_SUFFIX_WILDCARD_PREFIX)) {
|
||||||
|
return EMAIL_SUFFIX_DOMAIN_PATTERN.test(domain.slice(EMAIL_SUFFIX_WILDCARD_PREFIX.length))
|
||||||
|
}
|
||||||
|
return !domain.includes('*') && EMAIL_SUFFIX_DOMAIN_PATTERN.test(domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRegistrationEmailSuffixToken(value: string, strict: boolean): string {
|
||||||
|
if (value.startsWith(EMAIL_SUFFIX_WILDCARD_PREFIX)) {
|
||||||
|
const domain = value.slice(EMAIL_SUFFIX_WILDCARD_PREFIX.length)
|
||||||
|
if (strict && (!domain || EMAIL_SUFFIX_INVALID_CHAR_CHECK_RE.test(domain))) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return `${EMAIL_SUFFIX_WILDCARD_PREFIX}${domain.replace(EMAIL_SUFFIX_INVALID_CHAR_RE, '')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === '*') {
|
||||||
|
return strict ? '' : value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strict && EMAIL_SUFFIX_INVALID_CHAR_CHECK_RE.test(value)) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return value.replace(/[*]/g, '').replace(EMAIL_SUFFIX_INVALID_CHAR_RE, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCanonicalRegistrationEmailSuffix(domain: string): string {
|
||||||
|
return domain.startsWith(EMAIL_SUFFIX_WILDCARD_PREFIX) ? domain : `@${domain}`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1415,7 +1415,6 @@
|
|||||||
:key="suffix"
|
: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"
|
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>
|
<span>{{ suffix }}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -1436,10 +1435,6 @@
|
|||||||
<div
|
<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"
|
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
|
<input
|
||||||
v-model="registrationEmailSuffixWhitelistDraft"
|
v-model="registrationEmailSuffixWhitelistDraft"
|
||||||
type="text"
|
type="text"
|
||||||
@ -7983,8 +7978,8 @@ async function saveSettings() {
|
|||||||
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:
|
registration_email_suffix_whitelist:
|
||||||
registrationEmailSuffixWhitelistTags.value.map(
|
registrationEmailSuffixWhitelistTags.value.map((suffix) =>
|
||||||
(suffix) => `@${suffix}`,
|
suffix.startsWith("*.") ? 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,
|
||||||
|
|||||||
@ -164,6 +164,7 @@ import {
|
|||||||
import { apiClient } from '@/api/client'
|
import { apiClient } from '@/api/client'
|
||||||
import { buildAuthErrorMessage } from '@/utils/authError'
|
import { buildAuthErrorMessage } from '@/utils/authError'
|
||||||
import {
|
import {
|
||||||
|
formatRegistrationEmailSuffixWhitelistForMessage,
|
||||||
isRegistrationEmailSuffixAllowed,
|
isRegistrationEmailSuffixAllowed,
|
||||||
normalizeRegistrationEmailSuffixWhitelist
|
normalizeRegistrationEmailSuffixWhitelist
|
||||||
} from '@/utils/registrationEmailPolicy'
|
} from '@/utils/registrationEmailPolicy'
|
||||||
@ -574,7 +575,10 @@ function buildEmailSuffixNotAllowedMessage(): string {
|
|||||||
}
|
}
|
||||||
const separator = String(locale.value || '').toLowerCase().startsWith('zh') ? '、' : ', '
|
const separator = String(locale.value || '').toLowerCase().startsWith('zh') ? '、' : ', '
|
||||||
return t('auth.emailSuffixNotAllowedWithAllowed', {
|
return t('auth.emailSuffixNotAllowedWithAllowed', {
|
||||||
suffixes: normalizedWhitelist.join(separator)
|
suffixes: formatRegistrationEmailSuffixWhitelistForMessage(normalizedWhitelist, {
|
||||||
|
separator,
|
||||||
|
more: (count) => t('auth.emailSuffixAllowedMore', { count })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -318,6 +318,7 @@ import {
|
|||||||
} from '@/api/auth'
|
} from '@/api/auth'
|
||||||
import { buildAuthErrorMessage } from '@/utils/authError'
|
import { buildAuthErrorMessage } from '@/utils/authError'
|
||||||
import {
|
import {
|
||||||
|
formatRegistrationEmailSuffixWhitelistForMessage,
|
||||||
isRegistrationEmailSuffixAllowed,
|
isRegistrationEmailSuffixAllowed,
|
||||||
normalizeRegistrationEmailSuffixWhitelist
|
normalizeRegistrationEmailSuffixWhitelist
|
||||||
} from '@/utils/registrationEmailPolicy'
|
} from '@/utils/registrationEmailPolicy'
|
||||||
@ -739,7 +740,10 @@ function buildEmailSuffixNotAllowedMessage(): string {
|
|||||||
}
|
}
|
||||||
const separator = String(locale.value || '').toLowerCase().startsWith('zh') ? '、' : ', '
|
const separator = String(locale.value || '').toLowerCase().startsWith('zh') ? '、' : ', '
|
||||||
return t('auth.emailSuffixNotAllowedWithAllowed', {
|
return t('auth.emailSuffixNotAllowedWithAllowed', {
|
||||||
suffixes: normalizedWhitelist.join(separator)
|
suffixes: formatRegistrationEmailSuffixWhitelistForMessage(normalizedWhitelist, {
|
||||||
|
separator,
|
||||||
|
more: (count) => t('auth.emailSuffixAllowedMore', { count })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user