mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
Merge remote-tracking branch 'origin/pr/462'
This commit is contained in:
45
frontend/src/api/__tests__/auth-turnstile.spec.ts
Normal file
45
frontend/src/api/__tests__/auth-turnstile.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { postMock } = vi.hoisted(() => ({
|
||||
postMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
default: {
|
||||
post: postMock,
|
||||
},
|
||||
}))
|
||||
|
||||
import { authApi } from '@/api/auth'
|
||||
|
||||
describe('authApi turnstile payloads', () => {
|
||||
beforeEach(() => {
|
||||
postMock.mockReset()
|
||||
postMock.mockResolvedValue({ data: {} })
|
||||
})
|
||||
|
||||
it('includes turnstile token when sending email verification code', async () => {
|
||||
await authApi.sendVerificationCode('alice@example.com', 'turnstile-token')
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith('/api/auth/send-verification-code', {
|
||||
email: 'alice@example.com',
|
||||
turnstile_token: 'turnstile-token',
|
||||
})
|
||||
})
|
||||
|
||||
it('includes turnstile token when registering', async () => {
|
||||
await authApi.register({
|
||||
email: 'alice@example.com',
|
||||
username: 'alice',
|
||||
password: 'secret123',
|
||||
turnstile_token: 'turnstile-token',
|
||||
})
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith('/api/auth/register', {
|
||||
email: 'alice@example.com',
|
||||
username: 'alice',
|
||||
password: 'secret123',
|
||||
turnstile_token: 'turnstile-token',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -755,13 +755,13 @@ export const adminApi = {
|
||||
async getSystemConfig(
|
||||
key: string,
|
||||
options: { cacheTtlMs?: number } = {},
|
||||
): Promise<{ key: string; value: unknown }> {
|
||||
): Promise<{ key: string; value: unknown; is_set?: boolean }> {
|
||||
const cacheTtlMs = options.cacheTtlMs ?? 0
|
||||
const cacheKey = buildCacheKey('admin:system:config', { key })
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const response = await apiClient.get<{ key: string; value: unknown }>(
|
||||
const response = await apiClient.get<{ key: string; value: unknown; is_set?: boolean }>(
|
||||
`/api/admin/system/configs/${key}`
|
||||
)
|
||||
return response.data
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface UserStats {
|
||||
|
||||
export interface SendVerificationCodeRequest {
|
||||
email: string
|
||||
turnstile_token?: string
|
||||
}
|
||||
|
||||
export interface SendVerificationCodeResponse {
|
||||
@@ -67,6 +68,7 @@ export interface RegisterRequest {
|
||||
email?: string
|
||||
username: string
|
||||
password: string
|
||||
turnstile_token?: string
|
||||
}
|
||||
|
||||
export interface RegisterResponse {
|
||||
@@ -81,6 +83,9 @@ export interface RegistrationSettingsResponse {
|
||||
require_email_verification: boolean
|
||||
email_configured: boolean
|
||||
password_policy_level: string
|
||||
turnstile_enabled?: boolean
|
||||
turnstile_site_key?: string | null
|
||||
turnstile_required_actions?: string[]
|
||||
}
|
||||
|
||||
export interface AuthSettingsResponse {
|
||||
@@ -153,10 +158,17 @@ export const authApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
async sendVerificationCode(email: string): Promise<SendVerificationCodeResponse> {
|
||||
async sendVerificationCode(
|
||||
email: string,
|
||||
turnstileToken?: string
|
||||
): Promise<SendVerificationCodeResponse> {
|
||||
const payload: SendVerificationCodeRequest = { email }
|
||||
if (turnstileToken) {
|
||||
payload.turnstile_token = turnstileToken
|
||||
}
|
||||
const response = await apiClient.post<SendVerificationCodeResponse>(
|
||||
'/api/auth/send-verification-code',
|
||||
{ email }
|
||||
payload
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
145
frontend/src/features/auth/components/TurnstileWidget.vue
Normal file
145
frontend/src/features/auth/components/TurnstileWidget.vue
Normal 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>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -61,6 +61,11 @@
|
||||
:rate-limit-per-minute="systemConfig.rate_limit_per_minute"
|
||||
:enable-registration="systemConfig.enable_registration"
|
||||
:password-policy-level="systemConfig.password_policy_level"
|
||||
:turnstile-enabled="systemConfig.turnstile_enabled"
|
||||
:turnstile-site-key="systemConfig.turnstile_site_key"
|
||||
:turnstile-secret-key="systemConfig.turnstile_secret_key"
|
||||
:turnstile-secret-configured="systemConfig.turnstile_secret_key_is_set"
|
||||
:turnstile-allowed-hostnames-str="turnstileAllowedHostnamesStr"
|
||||
:auto-delete-expired-keys="systemConfig.auto_delete_expired_keys"
|
||||
:enable-format-conversion="systemConfig.enable_format_conversion"
|
||||
:enable-openai-image-sync-heartbeat="systemConfig.enable_openai_image_sync_heartbeat"
|
||||
@@ -71,6 +76,11 @@
|
||||
@update:rate-limit-per-minute="systemConfig.rate_limit_per_minute = $event"
|
||||
@update:enable-registration="systemConfig.enable_registration = $event"
|
||||
@update:password-policy-level="systemConfig.password_policy_level = $event"
|
||||
@update:turnstile-enabled="systemConfig.turnstile_enabled = $event"
|
||||
@update:turnstile-site-key="systemConfig.turnstile_site_key = $event"
|
||||
@update:turnstile-secret-key="systemConfig.turnstile_secret_key = $event"
|
||||
@update:turnstile-allowed-hostnames-str="turnstileAllowedHostnamesStr = $event"
|
||||
@clear-turnstile-secret="clearTurnstileSecret"
|
||||
@update:auto-delete-expired-keys="systemConfig.auto_delete_expired_keys = $event"
|
||||
@update:enable-format-conversion="systemConfig.enable_format_conversion = $event"
|
||||
@update:enable-openai-image-sync-heartbeat="systemConfig.enable_openai_image_sync_heartbeat = $event"
|
||||
@@ -309,11 +319,13 @@ const {
|
||||
maxRequestBodySizeKB,
|
||||
maxResponseBodySizeKB,
|
||||
sensitiveHeadersStr,
|
||||
turnstileAllowedHostnamesStr,
|
||||
loadSystemConfig,
|
||||
loadSystemVersion,
|
||||
saveSiteInfo,
|
||||
saveProxyConfig,
|
||||
saveBasicConfig,
|
||||
clearTurnstileSecret,
|
||||
saveLogConfig,
|
||||
saveCleanupConfig,
|
||||
handleAutoCleanupToggle,
|
||||
|
||||
@@ -171,6 +171,97 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-4 border-t pt-5">
|
||||
<div class="flex items-center h-full">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="turnstile-enabled"
|
||||
:checked="turnstileEnabled"
|
||||
@update:checked="$emit('update:turnstileEnabled', $event)"
|
||||
/>
|
||||
<div>
|
||||
<Label
|
||||
for="turnstile-enabled"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
注册人机验证
|
||||
</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
开启后注册与发送邮箱验证码前需要通过 Cloudflare Turnstile
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="turnstile-site-key"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
Turnstile Site Key
|
||||
</Label>
|
||||
<Input
|
||||
id="turnstile-site-key"
|
||||
:model-value="turnstileSiteKey || ''"
|
||||
type="text"
|
||||
placeholder="0x4AAAA..."
|
||||
class="mt-1"
|
||||
@update:model-value="$emit('update:turnstileSiteKey', String($event || '').trim() || null)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<Label
|
||||
for="turnstile-secret-key"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
Turnstile Secret Key
|
||||
</Label>
|
||||
<Button
|
||||
v-if="turnstileSecretConfigured"
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
class="h-auto p-0 text-xs"
|
||||
:disabled="loading"
|
||||
@click="$emit('clearTurnstileSecret')"
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
id="turnstile-secret-key"
|
||||
:model-value="turnstileSecretKey"
|
||||
type="password"
|
||||
:placeholder="turnstileSecretConfigured ? '已配置,留空不修改' : '输入 Secret Key'"
|
||||
class="mt-1"
|
||||
autocomplete="new-password"
|
||||
@update:model-value="$emit('update:turnstileSecretKey', String($event || ''))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="turnstile-hostnames"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
允许的 Hostname
|
||||
</Label>
|
||||
<Input
|
||||
id="turnstile-hostnames"
|
||||
:model-value="turnstileAllowedHostnamesStr"
|
||||
type="text"
|
||||
placeholder="example.com, app.example.com"
|
||||
class="mt-1"
|
||||
@update:model-value="$emit('update:turnstileAllowedHostnamesStr', String($event || ''))"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
留空则不额外校验 Cloudflare 返回的 hostname
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
</template>
|
||||
@@ -192,6 +283,11 @@ defineProps<{
|
||||
rateLimitPerMinute: number
|
||||
enableRegistration: boolean
|
||||
passwordPolicyLevel: string
|
||||
turnstileEnabled: boolean
|
||||
turnstileSiteKey: string | null
|
||||
turnstileSecretKey: string
|
||||
turnstileSecretConfigured: boolean
|
||||
turnstileAllowedHostnamesStr: string
|
||||
autoDeleteExpiredKeys: boolean
|
||||
enableFormatConversion: boolean
|
||||
enableOpenaiImageSyncHeartbeat: boolean
|
||||
@@ -205,6 +301,11 @@ defineEmits<{
|
||||
'update:rateLimitPerMinute': [value: number]
|
||||
'update:enableRegistration': [value: boolean]
|
||||
'update:passwordPolicyLevel': [value: string]
|
||||
'update:turnstileEnabled': [value: boolean]
|
||||
'update:turnstileSiteKey': [value: string | null]
|
||||
'update:turnstileSecretKey': [value: string]
|
||||
'update:turnstileAllowedHostnamesStr': [value: string]
|
||||
clearTurnstileSecret: []
|
||||
'update:autoDeleteExpiredKeys': [value: boolean]
|
||||
'update:enableFormatConversion': [value: boolean]
|
||||
'update:enableOpenaiImageSyncHeartbeat': [value: boolean]
|
||||
|
||||
@@ -15,6 +15,11 @@ export interface SystemConfig {
|
||||
rate_limit_per_minute: number
|
||||
enable_registration: boolean
|
||||
password_policy_level: string
|
||||
turnstile_enabled: boolean
|
||||
turnstile_site_key: string | null
|
||||
turnstile_secret_key: string
|
||||
turnstile_secret_key_is_set: boolean
|
||||
turnstile_allowed_hostnames: string[]
|
||||
// 独立余额 Key 过期管理
|
||||
auto_delete_expired_keys: boolean
|
||||
// 格式转换
|
||||
@@ -56,6 +61,10 @@ const CONFIG_KEYS = [
|
||||
'rate_limit_per_minute',
|
||||
'enable_registration',
|
||||
'password_policy_level',
|
||||
'turnstile_enabled',
|
||||
'turnstile_site_key',
|
||||
'turnstile_secret_key',
|
||||
'turnstile_allowed_hostnames',
|
||||
// 独立余额 Key 过期管理
|
||||
'auto_delete_expired_keys',
|
||||
// 格式转换
|
||||
@@ -98,6 +107,11 @@ function createDefaultConfig(): SystemConfig {
|
||||
rate_limit_per_minute: 0,
|
||||
enable_registration: false,
|
||||
password_policy_level: 'weak',
|
||||
turnstile_enabled: false,
|
||||
turnstile_site_key: null,
|
||||
turnstile_secret_key: '',
|
||||
turnstile_secret_key_is_set: false,
|
||||
turnstile_allowed_hostnames: [],
|
||||
// 独立余额 Key 过期管理
|
||||
auto_delete_expired_keys: false,
|
||||
// 格式转换
|
||||
@@ -165,6 +179,11 @@ export function useSystemConfig() {
|
||||
systemConfig.value.rate_limit_per_minute !== originalConfig.value.rate_limit_per_minute ||
|
||||
systemConfig.value.enable_registration !== originalConfig.value.enable_registration ||
|
||||
systemConfig.value.password_policy_level !== originalConfig.value.password_policy_level ||
|
||||
systemConfig.value.turnstile_enabled !== originalConfig.value.turnstile_enabled ||
|
||||
systemConfig.value.turnstile_site_key !== originalConfig.value.turnstile_site_key ||
|
||||
systemConfig.value.turnstile_secret_key.trim() !== '' ||
|
||||
JSON.stringify(systemConfig.value.turnstile_allowed_hostnames) !==
|
||||
JSON.stringify(originalConfig.value.turnstile_allowed_hostnames) ||
|
||||
systemConfig.value.auto_delete_expired_keys !== originalConfig.value.auto_delete_expired_keys ||
|
||||
systemConfig.value.enable_format_conversion !== originalConfig.value.enable_format_conversion ||
|
||||
systemConfig.value.enable_openai_image_sync_heartbeat !== originalConfig.value.enable_openai_image_sync_heartbeat
|
||||
@@ -233,12 +252,27 @@ export function useSystemConfig() {
|
||||
},
|
||||
})
|
||||
|
||||
const turnstileAllowedHostnamesStr = computed({
|
||||
get: () => systemConfig.value.turnstile_allowed_hostnames.join(', '),
|
||||
set: (val: string) => {
|
||||
systemConfig.value.turnstile_allowed_hostnames = val
|
||||
.split(',')
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter((s) => s.length > 0)
|
||||
},
|
||||
})
|
||||
|
||||
// 加载配置
|
||||
async function loadSystemConfig() {
|
||||
try {
|
||||
for (const key of CONFIG_KEYS) {
|
||||
try {
|
||||
const response = await adminApi.getSystemConfig(key)
|
||||
if (key === 'turnstile_secret_key') {
|
||||
systemConfig.value.turnstile_secret_key = ''
|
||||
systemConfig.value.turnstile_secret_key_is_set = !!response.is_set
|
||||
continue
|
||||
}
|
||||
if (response.value !== null && response.value !== undefined) {
|
||||
; (systemConfig.value as Record<string, unknown>)[key] = response.value
|
||||
}
|
||||
@@ -337,6 +371,21 @@ export function useSystemConfig() {
|
||||
value: systemConfig.value.password_policy_level,
|
||||
description: '密码策略等级',
|
||||
},
|
||||
{
|
||||
key: 'turnstile_enabled',
|
||||
value: systemConfig.value.turnstile_enabled,
|
||||
description: 'Cloudflare Turnstile 注册人机验证开关',
|
||||
},
|
||||
{
|
||||
key: 'turnstile_site_key',
|
||||
value: systemConfig.value.turnstile_site_key?.trim() || null,
|
||||
description: 'Cloudflare Turnstile 站点 Key',
|
||||
},
|
||||
{
|
||||
key: 'turnstile_allowed_hostnames',
|
||||
value: systemConfig.value.turnstile_allowed_hostnames,
|
||||
description: 'Cloudflare Turnstile 允许的 hostname 列表',
|
||||
},
|
||||
{
|
||||
key: 'auto_delete_expired_keys',
|
||||
value: systemConfig.value.auto_delete_expired_keys,
|
||||
@@ -353,6 +402,14 @@ export function useSystemConfig() {
|
||||
description: '同步生图心跳开关:开启后外层 HTTP 状态固定为 200,上游失败写入响应体',
|
||||
},
|
||||
]
|
||||
const turnstileSecret = systemConfig.value.turnstile_secret_key.trim()
|
||||
if (turnstileSecret) {
|
||||
configItems.push({
|
||||
key: 'turnstile_secret_key',
|
||||
value: turnstileSecret,
|
||||
description: 'Cloudflare Turnstile Secret Key',
|
||||
})
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
configItems.map((item) =>
|
||||
@@ -364,6 +421,17 @@ export function useSystemConfig() {
|
||||
originalConfig.value.rate_limit_per_minute = systemConfig.value.rate_limit_per_minute
|
||||
originalConfig.value.enable_registration = systemConfig.value.enable_registration
|
||||
originalConfig.value.password_policy_level = systemConfig.value.password_policy_level
|
||||
originalConfig.value.turnstile_enabled = systemConfig.value.turnstile_enabled
|
||||
originalConfig.value.turnstile_site_key = systemConfig.value.turnstile_site_key?.trim() || null
|
||||
originalConfig.value.turnstile_allowed_hostnames = [
|
||||
...systemConfig.value.turnstile_allowed_hostnames,
|
||||
]
|
||||
if (turnstileSecret) {
|
||||
systemConfig.value.turnstile_secret_key = ''
|
||||
systemConfig.value.turnstile_secret_key_is_set = true
|
||||
originalConfig.value.turnstile_secret_key = ''
|
||||
originalConfig.value.turnstile_secret_key_is_set = true
|
||||
}
|
||||
originalConfig.value.auto_delete_expired_keys =
|
||||
systemConfig.value.auto_delete_expired_keys
|
||||
originalConfig.value.enable_format_conversion =
|
||||
@@ -380,6 +448,29 @@ export function useSystemConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
async function clearTurnstileSecret() {
|
||||
basicConfigLoading.value = true
|
||||
try {
|
||||
await adminApi.updateSystemConfig(
|
||||
'turnstile_secret_key',
|
||||
'',
|
||||
'Cloudflare Turnstile Secret Key'
|
||||
)
|
||||
systemConfig.value.turnstile_secret_key = ''
|
||||
systemConfig.value.turnstile_secret_key_is_set = false
|
||||
if (originalConfig.value) {
|
||||
originalConfig.value.turnstile_secret_key = ''
|
||||
originalConfig.value.turnstile_secret_key_is_set = false
|
||||
}
|
||||
success('Turnstile 密钥已清空')
|
||||
} catch (err) {
|
||||
error('清空 Turnstile 密钥失败')
|
||||
log.error('清空 Turnstile 密钥失败:', err)
|
||||
} finally {
|
||||
basicConfigLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveLogConfig() {
|
||||
logConfigLoading.value = true
|
||||
try {
|
||||
@@ -559,6 +650,7 @@ export function useSystemConfig() {
|
||||
maxRequestBodySizeKB,
|
||||
maxResponseBodySizeKB,
|
||||
sensitiveHeadersStr,
|
||||
turnstileAllowedHostnamesStr,
|
||||
// 加载函数
|
||||
loadSystemConfig,
|
||||
loadSystemVersion,
|
||||
@@ -566,6 +658,7 @@ export function useSystemConfig() {
|
||||
saveSiteInfo,
|
||||
saveProxyConfig,
|
||||
saveBasicConfig,
|
||||
clearTurnstileSecret,
|
||||
saveLogConfig,
|
||||
saveCleanupConfig,
|
||||
handleAutoCleanupToggle,
|
||||
|
||||
Reference in New Issue
Block a user