mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-12 02:54:44 +08:00
feat(registration): add email domain whitelist policy
This commit is contained in:
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)
|
||||
}
|
||||
Reference in New Issue
Block a user