feat(auth): 添加 Turnstile 注册防护

This commit is contained in:
Entropy.Xu
2026-05-15 01:54:20 +08:00
parent bf511f9f8c
commit 85f48123b6
25 changed files with 1705 additions and 22 deletions
@@ -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')
})
})
@@ -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()
})
})