feat(registration): add email domain whitelist policy

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

View File

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

View File

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

View File

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

View File

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