Merge remote-tracking branch 'origin/pr/462'

This commit is contained in:
fawney19
2026-05-15 22:28:40 +08:00
25 changed files with 1705 additions and 22 deletions

View File

@@ -227,6 +227,8 @@
:require-email-verification="requireEmailVerification"
:email-configured="emailConfigured"
:password-policy-level="passwordPolicyLevel"
:turnstile-enabled="turnstileEnabled"
:turnstile-site-key="turnstileSiteKey"
@success="handleRegisterSuccess"
@switch-to-login="handleSwitchToLogin"
/>
@@ -271,6 +273,8 @@ const requireEmailVerification = ref(false)
const emailConfigured = ref(true) // 邮箱服务是否已配置
const passwordPolicyLevel = ref<PasswordPolicyLevel>('weak')
const allowRegistration = ref(false) // 由系统配置控制,默认关闭
const turnstileEnabled = ref(false)
const turnstileSiteKey = ref<string | null>(null)
// LDAP authentication settings
const PREFERRED_AUTH_TYPE_KEY = 'aether_preferred_auth_type'
@@ -388,6 +392,8 @@ onMounted(async () => {
requireEmailVerification.value = !!regSettings.require_email_verification
emailConfigured.value = !!regSettings.email_configured
passwordPolicyLevel.value = normalizePasswordPolicyLevel(regSettings.password_policy_level)
turnstileEnabled.value = !!regSettings.turnstile_enabled
turnstileSiteKey.value = regSettings.turnstile_site_key || null
localEnabled.value = authSettings.local_enabled
ldapEnabled.value = authSettings.ldap_enabled
@@ -413,6 +419,8 @@ onMounted(async () => {
requireEmailVerification.value = false
emailConfigured.value = false
passwordPolicyLevel.value = 'weak'
turnstileEnabled.value = false
turnstileSiteKey.value = null
localEnabled.value = true
ldapEnabled.value = false
ldapExclusive.value = false

View File

@@ -99,7 +99,9 @@
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span class="text-sm">正在发送验证码...</span>
<span class="text-sm">
{{ sendCodeLoadingText }}
</span>
</div>
<!-- 验证码输入框 -->
<template v-else>
@@ -194,6 +196,12 @@
两次输入的密码不一致
</p>
</div>
<TurnstileWidget
v-if="turnstileRequired && turnstileSiteKey"
ref="turnstileWidgetRef"
:site-key="turnstileSiteKey"
/>
</form>
<!-- 登录链接 -->
@@ -245,12 +253,15 @@ import { Dialog } from '@/components/ui'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import TurnstileWidget from './TurnstileWidget.vue'
interface Props {
open?: boolean
requireEmailVerification?: boolean
emailConfigured?: boolean
passwordPolicyLevel?: PasswordPolicyLevel
turnstileEnabled?: boolean
turnstileSiteKey?: string | null
}
interface Emits {
@@ -263,7 +274,9 @@ const props = withDefaults(defineProps<Props>(), {
open: false,
requireEmailVerification: false,
emailConfigured: true,
passwordPolicyLevel: 'weak'
passwordPolicyLevel: 'weak',
turnstileEnabled: false,
turnstileSiteKey: null
})
const emit = defineEmits<Emits>()
@@ -379,6 +392,9 @@ const codeSentAt = ref<number | null>(null)
const cooldownSeconds = ref(0)
const expireMinutes = ref(5)
const cooldownTimer = ref<number | null>(null)
const turnstileWidgetRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
const turnstileAction = ref<'send_verification_code' | 'register' | null>(null)
const turnstileRequired = computed(() => !!props.turnstileEnabled && !!props.turnstileSiteKey)
// Send code cooldown timer
const canSendCode = computed(() => {
@@ -388,13 +404,21 @@ const canSendCode = computed(() => {
})
const sendCodeButtonText = computed(() => {
if (isSendingCode.value) return '发送中...'
if (isSendingCode.value) {
return turnstileAction.value === 'send_verification_code' ? '验证中...' : '发送中...'
}
if (emailVerified.value) return '验证成功'
if (cooldownSeconds.value > 0) return `${cooldownSeconds.value}秒后重试`
if (codeSentAt.value) return '重新发送验证码'
return '发送验证码'
})
const sendCodeLoadingText = computed(() =>
turnstileAction.value === 'send_verification_code'
? '正在进行人机验证...'
: '正在发送验证码...'
)
// 用户名验证
const usernameRegex = /^[a-zA-Z0-9_.-]+$/
const usernameError = computed(() => {
@@ -563,6 +587,25 @@ const resetForm = () => {
// Clear verification code inputs
codeDigits.value = ['', '', '', '', '', '']
resetTurnstile()
}
const resetTurnstile = () => {
turnstileAction.value = null
turnstileWidgetRef.value?.reset()
}
const executeTurnstile = async (action: 'send_verification_code' | 'register') => {
if (!turnstileRequired.value) return undefined
turnstileAction.value = action
try {
return await turnstileWidgetRef.value?.execute(action)
} catch {
showError('人机验证失败,请重试', '验证失败')
return null
} finally {
turnstileAction.value = null
}
}
const handleSendCode = async () => {
@@ -581,7 +624,14 @@ const handleSendCode = async () => {
isSendingCode.value = true
try {
const response = await authApi.sendVerificationCode(formData.value.email)
const turnstileToken = await executeTurnstile('send_verification_code')
if (turnstileRequired.value && !turnstileToken) {
return
}
const response = await authApi.sendVerificationCode(
formData.value.email,
turnstileToken || undefined
)
if (response.success) {
codeSentAt.value = Date.now()
@@ -605,6 +655,7 @@ const handleSendCode = async () => {
showError(parseApiError(error, '网络错误,请重试'), '发送失败')
} finally {
isSendingCode.value = false
resetTurnstile()
}
}
@@ -659,11 +710,21 @@ const handleSubmit = async () => {
}
isLoading.value = true
loadingText.value = '注册中...'
loadingText.value = turnstileRequired.value ? '验证中...' : '注册中...'
try {
const turnstileToken = await executeTurnstile('register')
if (turnstileRequired.value && !turnstileToken) {
return
}
loadingText.value = '注册中...'
// 构建请求数据:邮箱可选
const registerData: { email?: string; username: string; password: string } = {
const registerData: {
email?: string
username: string
password: string
turnstile_token?: string
} = {
username: formData.value.username,
password: formData.value.password
}
@@ -671,6 +732,9 @@ const handleSubmit = async () => {
if (formData.value.email && formData.value.email.trim()) {
registerData.email = formData.value.email
}
if (turnstileToken) {
registerData.turnstile_token = turnstileToken
}
const response = await authApi.register(registerData)
@@ -682,6 +746,7 @@ const handleSubmit = async () => {
showError(parseApiError(error, '注册失败,请重试'), '注册失败')
} finally {
isLoading.value = false
resetTurnstile()
}
}

View File

@@ -0,0 +1,145 @@
<template>
<div
ref="containerRef"
class="min-h-[1px]"
/>
</template>
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue'
interface Props {
siteKey: string
}
type TurnstileWidgetId = string
interface TurnstileRenderOptions {
sitekey: string
action?: string
execution?: 'render' | 'execute'
appearance?: 'always' | 'execute' | 'interaction-only'
callback?: (token: string) => void
'error-callback'?: () => void
'expired-callback'?: () => void
'timeout-callback'?: () => void
}
interface TurnstileApi {
render: (container: HTMLElement, options: TurnstileRenderOptions) => TurnstileWidgetId
execute: (widgetId: TurnstileWidgetId) => void
reset: (widgetId: TurnstileWidgetId) => void
remove?: (widgetId: TurnstileWidgetId) => void
}
declare global {
interface Window {
turnstile?: TurnstileApi
__aetherTurnstileScriptPromise?: Promise<void>
}
}
const props = defineProps<Props>()
const containerRef = ref<HTMLElement | null>(null)
const widgetId = ref<TurnstileWidgetId | null>(null)
let pendingReject: ((error: Error) => void) | null = null
function loadTurnstileScript(): Promise<void> {
if (window.turnstile) {
return Promise.resolve()
}
if (window.__aetherTurnstileScriptPromise) {
return window.__aetherTurnstileScriptPromise
}
window.__aetherTurnstileScriptPromise = new Promise((resolve, reject) => {
const rejectAndReset = (script: HTMLScriptElement) => {
script.remove()
delete window.__aetherTurnstileScriptPromise
reject(new Error('Turnstile script failed'))
}
const existing = document.querySelector<HTMLScriptElement>(
'script[data-aether-turnstile="true"]'
)
if (existing) {
existing.addEventListener('load', () => resolve(), { once: true })
existing.addEventListener('error', () => rejectAndReset(existing), {
once: true,
})
return
}
const script = document.createElement('script')
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
script.async = true
script.defer = true
script.dataset.aetherTurnstile = 'true'
script.onload = () => resolve()
script.onerror = () => rejectAndReset(script)
document.head.appendChild(script)
})
return window.__aetherTurnstileScriptPromise
}
function clearWidget() {
if (!widgetId.value || !window.turnstile) return
if (window.turnstile.remove) {
window.turnstile.remove(widgetId.value)
} else {
window.turnstile.reset(widgetId.value)
}
widgetId.value = null
}
async function execute(action: string): Promise<string> {
await loadTurnstileScript()
const turnstile = window.turnstile
const container = containerRef.value
if (!turnstile || !container) {
throw new Error('Turnstile unavailable')
}
clearWidget()
return new Promise((resolve, reject) => {
pendingReject = reject
const id = turnstile.render(container, {
sitekey: props.siteKey,
action,
execution: 'execute',
appearance: 'interaction-only',
callback: (token: string) => {
pendingReject = null
resolve(token)
},
'error-callback': () => {
pendingReject = null
reject(new Error('Turnstile challenge failed'))
},
'expired-callback': () => {
pendingReject = null
reject(new Error('Turnstile token expired'))
},
'timeout-callback': () => {
pendingReject = null
reject(new Error('Turnstile challenge timed out'))
},
})
widgetId.value = id
turnstile.execute(id)
})
}
function reset() {
if (pendingReject) {
pendingReject(new Error('Turnstile reset'))
pendingReject = null
}
clearWidget()
}
onBeforeUnmount(reset)
defineExpose({
execute,
reset,
})
</script>

View File

@@ -0,0 +1,174 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick } from 'vue'
import RegisterDialog from '../RegisterDialog.vue'
const { registerMock, toastErrorMock, toastSuccessMock } = vi.hoisted(() => ({
registerMock: vi.fn(),
toastErrorMock: vi.fn(),
toastSuccessMock: vi.fn(),
}))
vi.mock('@/api/auth', () => ({
authApi: {
register: registerMock,
sendVerificationCode: vi.fn(),
verifyEmail: vi.fn(),
getVerificationStatus: vi.fn(),
},
}))
vi.mock('@/composables/useToast', () => ({
useToast: () => ({
success: toastSuccessMock,
error: toastErrorMock,
}),
}))
type TurnstileRenderOptions = {
callback?: (token: string) => void
'error-callback'?: () => void
}
type TurnstileMock = {
render: ReturnType<typeof vi.fn>
execute: ReturnType<typeof vi.fn>
reset: ReturnType<typeof vi.fn>
remove: ReturnType<typeof vi.fn>
}
function flushPromises() {
return new Promise((resolve) => window.setTimeout(resolve, 0))
}
function installTurnstileMock(mode: 'success' | 'error'): TurnstileMock {
let renderOptions: TurnstileRenderOptions | null = null
const turnstile = {
render: vi.fn((_container: HTMLElement, options: TurnstileRenderOptions) => {
renderOptions = options
return 'widget-id'
}),
execute: vi.fn(() => {
window.queueMicrotask(() => {
if (mode === 'success') {
renderOptions?.callback?.('turnstile-token')
} else {
renderOptions?.['error-callback']?.()
}
})
}),
reset: vi.fn(),
remove: vi.fn(),
}
;(window as unknown as { turnstile: TurnstileMock }).turnstile = turnstile
return turnstile
}
async function mountRegisterDialog() {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(RegisterDialog, {
open: true,
emailConfigured: false,
requireEmailVerification: false,
passwordPolicyLevel: 'weak',
turnstileEnabled: true,
turnstileSiteKey: 'site-public-key',
})
app.mount(root)
await nextTick()
return {
app,
root,
unmount: () => {
app.unmount()
root.remove()
},
}
}
async function fillRegistrationForm() {
const inputs = Array.from(document.body.querySelectorAll('input'))
const usernameInput = inputs.find((input) => input.placeholder === '请输入用户名')
const passwordInput = inputs.find((input) => input.placeholder.includes('至少'))
const confirmInput = inputs.find((input) => input.placeholder === '再次输入密码')
for (const [input, value] of [
[usernameInput, 'alice'],
[passwordInput, 'secret123'],
[confirmInput, 'secret123'],
] as const) {
expect(input).toBeTruthy()
input!.value = value
input!.dispatchEvent(new Event('input', { bubbles: true }))
}
await nextTick()
}
async function clickRegister() {
const registerButton = Array.from(document.body.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === '注册'
)
expect(registerButton).toBeTruthy()
expect(registerButton!.hasAttribute('disabled')).toBe(false)
registerButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
await flushPromises()
await flushPromises()
await nextTick()
}
describe('RegisterDialog Turnstile flow', () => {
let mounted: Awaited<ReturnType<typeof mountRegisterDialog>> | null = null
beforeEach(() => {
registerMock.mockReset()
registerMock.mockResolvedValue({ message: '注册成功' })
toastErrorMock.mockReset()
toastSuccessMock.mockReset()
})
afterEach(() => {
mounted?.unmount()
mounted = null
document.body.innerHTML = ''
delete (window as unknown as { turnstile?: TurnstileMock }).turnstile
delete (window as unknown as { __aetherTurnstileScriptPromise?: Promise<void> })
.__aetherTurnstileScriptPromise
})
it('gets a Turnstile token before submitting registration', async () => {
const turnstile = installTurnstileMock('success')
mounted = await mountRegisterDialog()
await fillRegistrationForm()
await clickRegister()
expect(turnstile.render).toHaveBeenCalledWith(
expect.any(HTMLElement),
expect.objectContaining({
sitekey: 'site-public-key',
action: 'register',
execution: 'execute',
})
)
expect(turnstile.execute).toHaveBeenCalledWith('widget-id')
expect(registerMock).toHaveBeenCalledWith({
username: 'alice',
password: 'secret123',
turnstile_token: 'turnstile-token',
})
expect(turnstile.remove).toHaveBeenCalledWith('widget-id')
})
it('resets Turnstile and blocks registration when verification fails', async () => {
const turnstile = installTurnstileMock('error')
mounted = await mountRegisterDialog()
await fillRegistrationForm()
await clickRegister()
expect(registerMock).not.toHaveBeenCalled()
expect(toastErrorMock).toHaveBeenCalledWith('人机验证失败,请重试', '验证失败')
expect(turnstile.remove).toHaveBeenCalledWith('widget-id')
})
})

View File

@@ -0,0 +1,56 @@
import { afterEach, describe, expect, it } from 'vitest'
import { createApp } from 'vue'
import TurnstileWidget from '../TurnstileWidget.vue'
function mountTurnstileWidget() {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(TurnstileWidget, { siteKey: 'site-public-key' })
const instance = app.mount(root) as unknown as {
execute: (action: string) => Promise<string>
}
return {
instance,
unmount: () => {
app.unmount()
root.remove()
},
}
}
function turnstileScripts() {
return Array.from(
document.querySelectorAll<HTMLScriptElement>('script[data-aether-turnstile="true"]')
)
}
describe('TurnstileWidget script loading', () => {
afterEach(() => {
document.body.innerHTML = ''
document.head.querySelectorAll('script[data-aether-turnstile="true"]').forEach((script) => {
script.remove()
})
delete (window as unknown as { turnstile?: unknown }).turnstile
delete (window as unknown as { __aetherTurnstileScriptPromise?: Promise<void> })
.__aetherTurnstileScriptPromise
})
it('retries loading the Turnstile script after a transient load failure', async () => {
const mounted = mountTurnstileWidget()
const firstAttempt = mounted.instance.execute('register')
const firstScript = turnstileScripts()[0]
expect(firstScript).toBeTruthy()
firstScript.dispatchEvent(new Event('error'))
await expect(firstAttempt).rejects.toThrow('Turnstile script failed')
const secondAttempt = mounted.instance.execute('register')
const scriptsAfterRetry = turnstileScripts()
expect(scriptsAfterRetry).toHaveLength(1)
expect(scriptsAfterRetry[0]).not.toBe(firstScript)
scriptsAfterRetry[0].dispatchEvent(new Event('error'))
await expect(secondAttempt).rejects.toThrow('Turnstile script failed')
mounted.unmount()
})
})