mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-30 00:21:34 +00:00
人机验证增加阿里云验证码 2.0
沿用腾讯天御验证码引入的多服务商模型:aliyun_captcha_enabled 作为独立 开关,与 Cloudflare Turnstile、腾讯天御三方互斥(保存校验 + 运行时 CAPTCHA_PROVIDER_CONFLICT)。后台「安全与认证」合并为单张人机验证卡片: 总开关 + 服务商单选(Turnstile / 腾讯天御 / 阿里云),选中即启用该家并 关闭其它,落库仍是三个独立开关键,由前端映射保证互斥。 阿里云侧同时支持 aliyun 中国站与国际站(alibabacloud.com):两站前端脚本、 region 取值与服务端 API 完全一致,仅账号与 AccessKey 相互独立,因此由 「服务地域」决定线路即可——中国内地走 captcha.cn-shanghai.aliyuncs.com, 非中国内地(新加坡)走 captcha.ap-southeast-1.aliyuncs.com,AccessKey 取自持有该实例的账号,无需在配置中区分站点。 - AliyunCaptchaService 对称 TencentCaptchaService:服务端校验走官方 SDK VerifyIntelligentCaptcha,调用异常按 fail-closed 拦截,与 Turnstile 网络错误行为对称;保存设置时真实探测 AK/SK 有效性 - 保护面对齐腾讯扩展入口:VerifyTencentCaptchaIfEnabled 通用化为 VerifyActionCaptchaIfEnabled,OAuth 登录启动、passkey 登录在阿里云 启用时同样拦截;Turnstile 维持既有覆盖不扩大 - 前端 AliyunCaptchaWidget 为表单内预验证按钮(popup 模式),同时暴露 verify() 供 OAuth 启动、passkey 等动作入口程序化弹窗;未预验证直接 提交时弹窗兜底。SDK 按钮绑定异步完成,弹窗未出现前按 tick 重试触发, 并轮询弹窗可见性识别用户关闭 - captchaVerifyParam 复用 turnstile_token 请求字段提交;公开设置下发 aliyun_captcha_enabled / scene_id / prefix / region - CSP 放行验证码 CDN:script-src/style-src 加 *.alicdn.com
This commit is contained in:
@@ -464,6 +464,12 @@ export interface SystemSettings {
|
||||
tencent_captcha_app_secret_key_configured: boolean;
|
||||
tencent_captcha_cloud_secret_id_configured: boolean;
|
||||
tencent_captcha_cloud_secret_key_configured: boolean;
|
||||
aliyun_captcha_enabled: boolean;
|
||||
aliyun_captcha_access_key_id: string;
|
||||
aliyun_captcha_access_key_secret_configured: boolean;
|
||||
aliyun_captcha_scene_id: string;
|
||||
aliyun_captcha_prefix: string;
|
||||
aliyun_captcha_region: string;
|
||||
api_key_acl_trust_forwarded_ip: boolean;
|
||||
forwarded_client_ip_headers: string[];
|
||||
|
||||
@@ -783,6 +789,12 @@ export interface UpdateSettingsRequest {
|
||||
tencent_captcha_app_secret_key?: string;
|
||||
tencent_captcha_cloud_secret_id?: string;
|
||||
tencent_captcha_cloud_secret_key?: string;
|
||||
aliyun_captcha_enabled?: boolean;
|
||||
aliyun_captcha_access_key_id?: string;
|
||||
aliyun_captcha_access_key_secret?: string;
|
||||
aliyun_captcha_scene_id?: string;
|
||||
aliyun_captcha_prefix?: string;
|
||||
aliyun_captcha_region?: string;
|
||||
api_key_acl_trust_forwarded_ip?: boolean;
|
||||
forwarded_client_ip_headers?: string[];
|
||||
linuxdo_connect_enabled?: boolean;
|
||||
|
||||
@@ -14,7 +14,7 @@ import type {
|
||||
SendVerifyCodeRequest,
|
||||
SendVerifyCodeResponse,
|
||||
PublicSettings,
|
||||
TencentCaptchaRequestProof,
|
||||
ActionCaptchaRequestProof,
|
||||
TotpLoginResponse,
|
||||
TotpLogin2FARequest
|
||||
} from '@/types'
|
||||
@@ -51,7 +51,7 @@ export function buildOAuthLoginStartURL(request: OAuthLoginStart): string {
|
||||
|
||||
export async function startOAuthLogin(
|
||||
request: OAuthLoginStart,
|
||||
proof: TencentCaptchaRequestProof
|
||||
proof: ActionCaptchaRequestProof
|
||||
): Promise<OAuthLoginStartResponse> {
|
||||
const { data } = await apiClient.post<OAuthLoginStartResponse>(
|
||||
`/auth/oauth/${request.provider}/start`,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { apiClient } from './client'
|
||||
import type { AuthResponse, TencentCaptchaRequestProof } from '@/types'
|
||||
import type { ActionCaptchaRequestProof, AuthResponse } from '@/types'
|
||||
|
||||
export interface PasskeyCredentialSummary {
|
||||
id: number
|
||||
@@ -104,7 +104,7 @@ function serializeAssertionCredential(credential: PublicKeyCredential): Record<s
|
||||
}
|
||||
}
|
||||
|
||||
async function login(proof?: TencentCaptchaRequestProof): Promise<AuthResponse> {
|
||||
async function login(proof?: ActionCaptchaRequestProof): Promise<AuthResponse> {
|
||||
requirePasskeySupport()
|
||||
const { data: begin } = proof
|
||||
? await apiClient.post<CeremonyOptionsResponse>('/auth/passkey/login/begin', proof)
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
<template>
|
||||
<div v-if="sceneId && prefix" class="aliyun-captcha-wrapper">
|
||||
<button
|
||||
:id="buttonId"
|
||||
type="button"
|
||||
class="aliyun-captcha-button"
|
||||
:class="state === 'verified' ? 'aliyun-captcha-button--verified' : ''"
|
||||
:disabled="state === 'verified'"
|
||||
>
|
||||
<svg
|
||||
v-if="state === 'verified'"
|
||||
class="aliyun-captcha-icon"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
class="aliyun-captcha-icon"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M9.661 2.237a.531.531 0 01.678 0 11.947 11.947 0 007.078 2.749.5.5 0 01.479.425c.069.52.104 1.05.104 1.59 0 5.162-3.26 9.563-7.834 11.256a.48.48 0 01-.332 0C5.26 16.564 2 12.163 2 7c0-.538.035-1.069.104-1.589a.5.5 0 01.48-.425 11.947 11.947 0 007.077-2.75z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>{{ buttonText }}</span>
|
||||
</button>
|
||||
<div :id="elementId"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
interface AliyunCaptchaVerifyResult {
|
||||
captchaResult: boolean
|
||||
bizResult?: boolean
|
||||
}
|
||||
|
||||
interface AliyunCaptchaInitOptions {
|
||||
SceneId: string
|
||||
prefix: string
|
||||
mode: 'popup' | 'embed'
|
||||
element: string
|
||||
button: string
|
||||
captchaVerifyCallback: (
|
||||
captchaVerifyParam: string
|
||||
) => AliyunCaptchaVerifyResult | Promise<AliyunCaptchaVerifyResult>
|
||||
onBizResultCallback: (bizResult: boolean) => void
|
||||
getInstance: (instance: unknown) => void
|
||||
slideStyle?: { width: number; height: number }
|
||||
language?: string
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
initAliyunCaptcha?: (options: AliyunCaptchaInitOptions) => void
|
||||
AliyunCaptchaConfig?: { region: string; prefix: string }
|
||||
}
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
sceneId: string
|
||||
prefix: string
|
||||
region?: 'cn' | 'sgp'
|
||||
}>(),
|
||||
{
|
||||
region: 'cn'
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'verify', param: string): void
|
||||
(e: 'expire'): void
|
||||
(e: 'error'): void
|
||||
}>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const uid = Math.random().toString(36).slice(2, 10)
|
||||
const buttonId = `aliyun-captcha-button-${uid}`
|
||||
const elementId = `aliyun-captcha-element-${uid}`
|
||||
|
||||
// idle: 未验证可点击;verifying: 弹窗已拉起(关闭后回到 idle 可重试);verified: 已通过
|
||||
const state = ref<'idle' | 'verifying' | 'verified'>('idle')
|
||||
|
||||
const buttonText = computed(() => {
|
||||
switch (state.value) {
|
||||
case 'verified':
|
||||
return t('auth.captchaVerified')
|
||||
case 'verifying':
|
||||
return t('auth.captchaVerifying')
|
||||
default:
|
||||
return t('auth.captchaClickToVerify')
|
||||
}
|
||||
})
|
||||
|
||||
const SCRIPT_SRC = 'https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js'
|
||||
const POPUP_ID = 'aliyunCaptcha-window-popup'
|
||||
const MASK_ID = 'aliyunCaptcha-mask'
|
||||
const POPUP_OPEN_TIMEOUT_MS = 8000
|
||||
const POPUP_WATCH_INTERVAL_MS = 300
|
||||
|
||||
// captchaVerifyParam 是一次性参数:verified 后缓存于此,提交失败需 reset 后重新验证
|
||||
let cachedParam: string | null = null
|
||||
let pending: { resolve: (value: string | null) => void } | null = null
|
||||
let popupWatchTimer: number | null = null
|
||||
let readyPromise: Promise<void> | null = null
|
||||
|
||||
const loadScript = (): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 全局配置必须在脚本加载前就位(region/prefix 全站一致,重复赋值无副作用)
|
||||
window.AliyunCaptchaConfig = { region: props.region, prefix: props.prefix }
|
||||
|
||||
if (window.initAliyunCaptcha) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
const existingScript = document.querySelector<HTMLScriptElement>(
|
||||
'script[src*="aliyunCaptcha/AliyunCaptcha"]'
|
||||
)
|
||||
if (existingScript) {
|
||||
existingScript.addEventListener('load', () => resolve())
|
||||
existingScript.addEventListener('error', () =>
|
||||
reject(new Error('Failed to load Aliyun captcha script'))
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.src = SCRIPT_SRC
|
||||
script.async = true
|
||||
script.onload = () => resolve()
|
||||
script.onerror = () => reject(new Error('Failed to load Aliyun captcha script'))
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
}
|
||||
|
||||
function initCaptcha(): void {
|
||||
if (!window.initAliyunCaptcha) {
|
||||
throw new Error('Aliyun captcha script not ready')
|
||||
}
|
||||
window.initAliyunCaptcha({
|
||||
SceneId: props.sceneId,
|
||||
prefix: props.prefix,
|
||||
mode: 'popup',
|
||||
element: `#${elementId}`,
|
||||
button: `#${buttonId}`,
|
||||
// 这里不发业务请求,只把 captchaVerifyParam 当 token 交给页面,随登录/注册等
|
||||
// 业务请求的 turnstile_token 字段提交,由后端在业务接口内调阿里云校验。
|
||||
captchaVerifyCallback: (captchaVerifyParam: string) => {
|
||||
onCaptchaParam(captchaVerifyParam)
|
||||
return { captchaResult: true }
|
||||
},
|
||||
onBizResultCallback: () => {},
|
||||
getInstance: () => {},
|
||||
slideStyle: { width: 360, height: 40 },
|
||||
language: locale.value.toLowerCase().startsWith('zh') ? 'cn' : 'en'
|
||||
})
|
||||
}
|
||||
|
||||
function ensureReady(): Promise<void> {
|
||||
if (!readyPromise) {
|
||||
readyPromise = loadScript().then(() => initCaptcha())
|
||||
readyPromise.catch(() => {
|
||||
// 失败后允许下次重试(如网络恢复)
|
||||
readyPromise = null
|
||||
})
|
||||
}
|
||||
return readyPromise
|
||||
}
|
||||
|
||||
function onCaptchaParam(param: string): void {
|
||||
stopPopupWatch()
|
||||
cachedParam = param
|
||||
state.value = 'verified'
|
||||
emit('verify', param)
|
||||
const current = pending
|
||||
pending = null
|
||||
current?.resolve(param)
|
||||
}
|
||||
|
||||
function settlePending(value: string | null): void {
|
||||
const current = pending
|
||||
pending = null
|
||||
current?.resolve(value)
|
||||
}
|
||||
|
||||
function isPopupVisible(): boolean {
|
||||
const popup = document.getElementById(POPUP_ID)
|
||||
if (!popup) return false
|
||||
return window.getComputedStyle(popup).display !== 'none'
|
||||
}
|
||||
|
||||
function stopPopupWatch(): void {
|
||||
if (popupWatchTimer !== null) {
|
||||
window.clearInterval(popupWatchTimer)
|
||||
popupWatchTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// SDK 没有用户关闭弹窗的回调,靠轮询弹窗可见性兜底:
|
||||
// 弹窗出现过又消失且未产生 param → 用户主动关闭;迟迟未出现 → 打开失败。
|
||||
// initAliyunCaptcha 对触发按钮的事件绑定是异步完成的,首次 click 可能落空,
|
||||
// 因此弹窗出现前每个 tick 重试触发一次。
|
||||
function startPopupWatch(): void {
|
||||
stopPopupWatch()
|
||||
const startedAt = Date.now()
|
||||
let seen = false
|
||||
popupWatchTimer = window.setInterval(() => {
|
||||
if (state.value === 'verified') {
|
||||
stopPopupWatch()
|
||||
return
|
||||
}
|
||||
if (isPopupVisible()) {
|
||||
seen = true
|
||||
return
|
||||
}
|
||||
if (seen || Date.now() - startedAt > POPUP_OPEN_TIMEOUT_MS) {
|
||||
stopPopupWatch()
|
||||
state.value = 'idle'
|
||||
settlePending(null)
|
||||
return
|
||||
}
|
||||
document.getElementById(buttonId)?.click()
|
||||
}, POPUP_WATCH_INTERVAL_MS)
|
||||
}
|
||||
|
||||
// 用户点击与程序化触发共用:置 verifying 并启动弹窗监视(幂等,重试 click 不重置计时)
|
||||
function handleTriggerClick(): void {
|
||||
if (state.value === 'verified') {
|
||||
return
|
||||
}
|
||||
state.value = 'verifying'
|
||||
if (popupWatchTimer === null) {
|
||||
startPopupWatch()
|
||||
}
|
||||
}
|
||||
|
||||
// 程序化触发验证(OAuth 启动、passkey、未预验证时的表单提交兜底):
|
||||
// 已通过预验证则直接复用缓存的 captchaVerifyParam;否则弹出验证码等待结果。
|
||||
// 用户关闭/未能弹出 resolve null;脚本加载失败 reject。
|
||||
async function verify(): Promise<string | null> {
|
||||
if (state.value === 'verified' && cachedParam) {
|
||||
return cachedParam
|
||||
}
|
||||
settlePending(null)
|
||||
await ensureReady()
|
||||
return new Promise<string | null>((resolve) => {
|
||||
pending = { resolve }
|
||||
document.getElementById(buttonId)?.click()
|
||||
})
|
||||
}
|
||||
|
||||
// 重置为未验证态;captchaVerifyParam 是一次性参数,服务端校验失败后需用户重新验证
|
||||
function reset(): void {
|
||||
stopPopupWatch()
|
||||
settlePending(null)
|
||||
cachedParam = null
|
||||
state.value = 'idle'
|
||||
}
|
||||
|
||||
defineExpose({ verify, reset })
|
||||
|
||||
onMounted(async () => {
|
||||
if (!props.sceneId || !props.prefix) {
|
||||
return
|
||||
}
|
||||
|
||||
document.getElementById(buttonId)?.addEventListener('click', handleTriggerClick)
|
||||
try {
|
||||
await ensureReady()
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize Aliyun captcha:', error)
|
||||
emit('error')
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.getElementById(buttonId)?.removeEventListener('click', handleTriggerClick)
|
||||
stopPopupWatch()
|
||||
settlePending(null)
|
||||
// SDK 不会自清理弹窗 DOM,残留会导致下次挂载时回调重复触发
|
||||
document.getElementById(MASK_ID)?.remove()
|
||||
document.getElementById(POPUP_ID)?.remove()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aliyun-captcha-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.aliyun-captcha-button {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgb(209 213 219);
|
||||
background-color: rgb(249 250 251);
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: rgb(55 65 81);
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
.aliyun-captcha-button:hover:not(:disabled) {
|
||||
border-color: rgb(156 163 175);
|
||||
background-color: rgb(243 244 246);
|
||||
}
|
||||
|
||||
.aliyun-captcha-button--verified {
|
||||
border-color: rgb(34 197 94);
|
||||
background-color: rgb(240 253 244);
|
||||
color: rgb(21 128 61);
|
||||
}
|
||||
|
||||
:root.dark .aliyun-captcha-button,
|
||||
.dark .aliyun-captcha-button {
|
||||
border-color: rgb(55 65 81);
|
||||
background-color: rgb(31 41 55);
|
||||
color: rgb(209 213 219);
|
||||
}
|
||||
|
||||
:root.dark .aliyun-captcha-button:hover:not(:disabled),
|
||||
.dark .aliyun-captcha-button:hover:not(:disabled) {
|
||||
border-color: rgb(75 85 99);
|
||||
background-color: rgb(55 65 81);
|
||||
}
|
||||
|
||||
:root.dark .aliyun-captcha-button--verified,
|
||||
.dark .aliyun-captcha-button--verified {
|
||||
border-color: rgb(34 197 94);
|
||||
background-color: rgb(20 83 45 / 0.3);
|
||||
color: rgb(134 239 172);
|
||||
}
|
||||
|
||||
.aliyun-captcha-icon {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -12,13 +12,30 @@
|
||||
ref="tencentRef"
|
||||
:app-id="tencentAppId"
|
||||
/>
|
||||
<AliyunCaptchaWidget
|
||||
v-else-if="aliyunEnabled && aliyunSceneId && aliyunPrefix"
|
||||
ref="aliyunRef"
|
||||
:scene-id="aliyunSceneId"
|
||||
:prefix="aliyunPrefix"
|
||||
:region="aliyunRegion === 'sgp' ? 'sgp' : 'cn'"
|
||||
@verify="(param: string) => emit('verify', param, '')"
|
||||
@expire="emit('expire')"
|
||||
@error="emit('error')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||
import TencentCaptchaGate from '@/components/TencentCaptchaGate.vue'
|
||||
import type { TencentCaptchaProof } from '@/utils/tencentCaptcha'
|
||||
import AliyunCaptchaWidget from '@/components/AliyunCaptchaWidget.vue'
|
||||
|
||||
// ActionCaptchaResult 动作触发式验证(腾讯/阿里云弹窗)的结果:
|
||||
// 腾讯 token=ticket、randstr 非空;阿里云 token=captchaVerifyParam、randstr 恒为空。
|
||||
export interface ActionCaptchaResult {
|
||||
token: string
|
||||
randstr: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
siteKey?: string
|
||||
@@ -26,6 +43,10 @@ const props = defineProps<{
|
||||
turnstileSiteKey: string
|
||||
tencentEnabled: boolean
|
||||
tencentAppId: string
|
||||
aliyunEnabled?: boolean
|
||||
aliyunSceneId?: string
|
||||
aliyunPrefix?: string
|
||||
aliyunRegion?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -36,21 +57,39 @@ const emit = defineEmits<{
|
||||
|
||||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||||
const tencentRef = ref<InstanceType<typeof TencentCaptchaGate> | null>(null)
|
||||
const aliyunRef = ref<InstanceType<typeof AliyunCaptchaWidget> | null>(null)
|
||||
|
||||
function reset(): void {
|
||||
turnstileRef.value?.reset()
|
||||
tencentRef.value?.reset()
|
||||
aliyunRef.value?.reset()
|
||||
}
|
||||
|
||||
async function verifyTencent(): Promise<TencentCaptchaProof | null> {
|
||||
if (!props.tencentEnabled || !props.tencentAppId) return null
|
||||
try {
|
||||
return (await tencentRef.value?.verify()) ?? null
|
||||
} catch {
|
||||
emit('error')
|
||||
return null
|
||||
// verifyAction 弹出当前启用的动作触发式验证码(腾讯/阿里云)并等待结果;
|
||||
// 用户关闭弹窗返回 null,验证异常 emit('error') 并返回 null。
|
||||
async function verifyAction(): Promise<ActionCaptchaResult | null> {
|
||||
if (props.tencentEnabled && props.tencentAppId) {
|
||||
try {
|
||||
const proof = (await tencentRef.value?.verify()) ?? null
|
||||
if (!proof) return null
|
||||
return { token: proof.ticket, randstr: proof.randstr }
|
||||
} catch {
|
||||
emit('error')
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (props.aliyunEnabled && props.aliyunSceneId && props.aliyunPrefix) {
|
||||
try {
|
||||
const param = (await aliyunRef.value?.verify()) ?? null
|
||||
if (!param) return null
|
||||
return { token: param, randstr: '' }
|
||||
} catch {
|
||||
emit('error')
|
||||
return null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
defineExpose({ reset, verifyTencent })
|
||||
defineExpose({ reset, verifyAction })
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import AliyunCaptchaWidget from '../AliyunCaptchaWidget.vue'
|
||||
|
||||
interface CapturedInitOptions {
|
||||
SceneId: string
|
||||
prefix: string
|
||||
mode: string
|
||||
element: string
|
||||
button: string
|
||||
captchaVerifyCallback: (param: string) => { captchaResult: boolean }
|
||||
language?: string
|
||||
}
|
||||
|
||||
const i18nStub = {
|
||||
install(app: { config: { globalProperties: Record<string, unknown> } }) {
|
||||
app.config.globalProperties.$t = (key: string) => key
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ locale: { value: 'zh-CN' }, t: (key: string) => key })
|
||||
}))
|
||||
|
||||
describe('AliyunCaptchaWidget', () => {
|
||||
let initOptions: CapturedInitOptions | null
|
||||
|
||||
beforeEach(() => {
|
||||
initOptions = null
|
||||
window.initAliyunCaptcha = vi.fn((options: CapturedInitOptions) => {
|
||||
initOptions = options
|
||||
}) as unknown as typeof window.initAliyunCaptcha
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
delete window.initAliyunCaptcha
|
||||
delete window.AliyunCaptchaConfig
|
||||
document.getElementById('aliyunCaptcha-window-popup')?.remove()
|
||||
document.getElementById('aliyunCaptcha-mask')?.remove()
|
||||
})
|
||||
|
||||
function mountWidget() {
|
||||
return mount(AliyunCaptchaWidget, {
|
||||
props: { sceneId: 'scene-1', prefix: 'prefix-1', region: 'cn' as const },
|
||||
attachTo: document.body,
|
||||
global: { plugins: [i18nStub] }
|
||||
})
|
||||
}
|
||||
|
||||
function createVisiblePopup(): HTMLElement {
|
||||
const popup = document.createElement('div')
|
||||
popup.id = 'aliyunCaptcha-window-popup'
|
||||
popup.style.display = 'block'
|
||||
document.body.appendChild(popup)
|
||||
return popup
|
||||
}
|
||||
|
||||
it('渲染可见验证按钮并以 popup 模式初始化,全局配置就位', async () => {
|
||||
const wrapper = mountWidget()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
const button = wrapper.get('button')
|
||||
expect(button.text()).toContain('auth.captchaClickToVerify')
|
||||
expect(window.AliyunCaptchaConfig).toEqual({ region: 'cn', prefix: 'prefix-1' })
|
||||
expect(initOptions).not.toBeNull()
|
||||
expect(initOptions!.mode).toBe('popup')
|
||||
expect(initOptions!.SceneId).toBe('scene-1')
|
||||
expect(initOptions!.language).toBe('cn')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('用户点击按钮进入验证中,验证完成后 emit verify 并置已通过', async () => {
|
||||
const wrapper = mountWidget()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
expect(wrapper.get('button').text()).toContain('auth.captchaVerifying')
|
||||
|
||||
const result = initOptions!.captchaVerifyCallback('captcha-param-1')
|
||||
expect(result).toEqual({ captchaResult: true })
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.emitted('verify')).toEqual([['captcha-param-1']])
|
||||
expect(wrapper.get('button').text()).toContain('auth.captchaVerified')
|
||||
expect(wrapper.get('button').attributes('disabled')).toBeDefined()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('verify() 在已预验证时直接复用缓存的 captchaVerifyParam', async () => {
|
||||
const wrapper = mountWidget()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
initOptions!.captchaVerifyCallback('captcha-param-2')
|
||||
|
||||
const vm = wrapper.vm as unknown as { verify: () => Promise<string | null> }
|
||||
await expect(vm.verify()).resolves.toBe('captcha-param-2')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('verify() 在未预验证时触发弹窗流程并等待结果', async () => {
|
||||
const wrapper = mountWidget()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
const vm = wrapper.vm as unknown as { verify: () => Promise<string | null> }
|
||||
const pending = vm.verify()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.get('button').text()).toContain('auth.captchaVerifying')
|
||||
|
||||
initOptions!.captchaVerifyCallback('captcha-param-3')
|
||||
await expect(pending).resolves.toBe('captcha-param-3')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('弹窗未出现前会按 tick 重试触发按钮(SDK 异步绑定兜底)', async () => {
|
||||
vi.useFakeTimers()
|
||||
const wrapper = mountWidget()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
const vm = wrapper.vm as unknown as { verify: () => Promise<string | null> }
|
||||
void vm.verify()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
const button = wrapper.get('button').element as HTMLButtonElement
|
||||
const clickSpy = vi.fn()
|
||||
button.addEventListener('click', clickSpy)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(clickSpy.mock.calls.length).toBeGreaterThanOrEqual(3)
|
||||
|
||||
button.removeEventListener('click', clickSpy)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('弹窗出现后被用户关闭时 resolve null 并回到未验证态', async () => {
|
||||
vi.useFakeTimers()
|
||||
const wrapper = mountWidget()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
const vm = wrapper.vm as unknown as { verify: () => Promise<string | null> }
|
||||
const pending = vm.verify()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
const popup = createVisiblePopup()
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
popup.remove()
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
|
||||
await expect(pending).resolves.toBeNull()
|
||||
expect(wrapper.get('button').text()).toContain('auth.captchaClickToVerify')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('reset() 清空缓存并取消进行中的验证', async () => {
|
||||
const wrapper = mountWidget()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
initOptions!.captchaVerifyCallback('captcha-param-4')
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
verify: () => Promise<string | null>
|
||||
reset: () => void
|
||||
}
|
||||
vm.reset()
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.get('button').text()).toContain('auth.captchaClickToVerify')
|
||||
|
||||
// reset 后缓存失效,verify() 重新走弹窗流程
|
||||
const pending = vm.verify()
|
||||
await Promise.resolve()
|
||||
initOptions!.captchaVerifyCallback('captcha-param-5')
|
||||
await expect(pending).resolves.toBe('captcha-param-5')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
initAliyunCaptcha?: (options: unknown) => void
|
||||
AliyunCaptchaConfig?: { region: string; prefix: string }
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,10 @@
|
||||
:turnstile-site-key="turnstileSiteKey"
|
||||
:tencent-enabled="tencentCaptchaEnabled"
|
||||
:tencent-app-id="tencentCaptchaAppId"
|
||||
:aliyun-enabled="aliyunCaptchaEnabled"
|
||||
:aliyun-scene-id="aliyunCaptchaSceneId"
|
||||
:aliyun-prefix="aliyunCaptchaPrefix"
|
||||
:aliyun-region="aliyunCaptchaRegion"
|
||||
@verify="onTurnstileVerify"
|
||||
@expire="onTurnstileExpire"
|
||||
@error="onTurnstileError"
|
||||
@@ -137,13 +141,28 @@ const turnstileEnabled = ref(false)
|
||||
const turnstileSiteKey = ref('')
|
||||
const tencentCaptchaEnabled = ref(false)
|
||||
const tencentCaptchaAppId = ref('')
|
||||
const aliyunCaptchaEnabled = ref(false)
|
||||
const aliyunCaptchaSceneId = ref('')
|
||||
const aliyunCaptchaPrefix = ref('')
|
||||
const aliyunCaptchaRegion = ref('cn')
|
||||
const turnstileToken = ref('')
|
||||
const tencentCaptchaRandstr = ref('')
|
||||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||||
const aliyunCaptchaReady = computed(
|
||||
() =>
|
||||
aliyunCaptchaEnabled.value &&
|
||||
Boolean(aliyunCaptchaSceneId.value) &&
|
||||
Boolean(aliyunCaptchaPrefix.value)
|
||||
)
|
||||
// 动作触发式验证码(腾讯/阿里云):发送验证码、提交时弹窗验证
|
||||
const actionCaptchaEnabled = computed(
|
||||
() =>
|
||||
(tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value)) ||
|
||||
aliyunCaptchaReady.value
|
||||
)
|
||||
const captchaEnabled = computed(
|
||||
() =>
|
||||
(turnstileEnabled.value && Boolean(turnstileSiteKey.value)) ||
|
||||
(tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value))
|
||||
(turnstileEnabled.value && Boolean(turnstileSiteKey.value)) || actionCaptchaEnabled.value
|
||||
)
|
||||
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
@@ -229,13 +248,13 @@ function onTurnstileError() {
|
||||
sendCodeError.value = t('auth.turnstileFailed')
|
||||
}
|
||||
|
||||
async function acquireTencentProof(): Promise<boolean> {
|
||||
if (!tencentCaptchaEnabled.value) return true
|
||||
async function acquireActionProof(): Promise<boolean> {
|
||||
if (!actionCaptchaEnabled.value) return true
|
||||
|
||||
const proof = await turnstileRef.value?.verifyTencent()
|
||||
const proof = await turnstileRef.value?.verifyAction()
|
||||
if (!proof) return false
|
||||
|
||||
turnstileToken.value = proof.ticket
|
||||
turnstileToken.value = proof.token
|
||||
tencentCaptchaRandstr.value = proof.randstr
|
||||
return true
|
||||
}
|
||||
@@ -251,7 +270,7 @@ async function handleSendCode() {
|
||||
return
|
||||
}
|
||||
|
||||
if (!(await acquireTencentProof())) {
|
||||
if (!(await acquireActionProof())) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -262,7 +281,8 @@ async function handleSendCode() {
|
||||
try {
|
||||
const response = await sendPendingOAuthVerifyCode({
|
||||
email: trimmedEmail,
|
||||
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined,
|
||||
turnstile_token:
|
||||
turnstileEnabled.value || aliyunCaptchaEnabled.value ? turnstileToken.value : undefined,
|
||||
tencent_captcha_ticket: tencentCaptchaEnabled.value ? turnstileToken.value : undefined,
|
||||
tencent_captcha_randstr: tencentCaptchaEnabled.value ? tencentCaptchaRandstr.value : undefined
|
||||
})
|
||||
@@ -292,7 +312,7 @@ async function handleSubmit() {
|
||||
return
|
||||
}
|
||||
|
||||
if (!(await acquireTencentProof())) {
|
||||
if (!(await acquireActionProof())) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -300,7 +320,9 @@ async function handleSubmit() {
|
||||
email: trimmedEmail,
|
||||
password: password.value,
|
||||
verifyCode: emailVerifyEnabled.value ? verifyCode.value.trim() : '',
|
||||
...(turnstileEnabled.value && turnstileToken.value ? { turnstileToken: turnstileToken.value } : {}),
|
||||
...((turnstileEnabled.value || aliyunCaptchaEnabled.value) && turnstileToken.value
|
||||
? { turnstileToken: turnstileToken.value }
|
||||
: {}),
|
||||
...(tencentCaptchaEnabled.value && turnstileToken.value
|
||||
? {
|
||||
tencentCaptchaTicket: turnstileToken.value,
|
||||
@@ -310,7 +332,7 @@ async function handleSubmit() {
|
||||
invitationCode: invitationCode.value.trim() || undefined
|
||||
})
|
||||
|
||||
if (tencentCaptchaEnabled.value) {
|
||||
if (actionCaptchaEnabled.value) {
|
||||
resetTurnstile()
|
||||
}
|
||||
}
|
||||
@@ -328,6 +350,10 @@ onMounted(async () => {
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||
tencentCaptchaEnabled.value = settings.tencent_captcha_enabled === true
|
||||
tencentCaptchaAppId.value = settings.tencent_captcha_app_id || ''
|
||||
aliyunCaptchaEnabled.value = settings.aliyun_captcha_enabled === true
|
||||
aliyunCaptchaSceneId.value = settings.aliyun_captcha_scene_id || ''
|
||||
aliyunCaptchaPrefix.value = settings.aliyun_captcha_prefix || ''
|
||||
aliyunCaptchaRegion.value = settings.aliyun_captcha_region || 'cn'
|
||||
} catch {
|
||||
invitationCodeEnabled.value = false
|
||||
emailVerifyEnabled.value = true
|
||||
@@ -335,6 +361,10 @@ onMounted(async () => {
|
||||
turnstileSiteKey.value = ''
|
||||
tencentCaptchaEnabled.value = false
|
||||
tencentCaptchaAppId.value = ''
|
||||
aliyunCaptchaEnabled.value = false
|
||||
aliyunCaptchaSceneId.value = ''
|
||||
aliyunCaptchaPrefix.value = ''
|
||||
aliyunCaptchaRegion.value = 'cn'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ const sendPendingOAuthVerifyCode = vi.fn()
|
||||
const getPublicSettings = vi.fn()
|
||||
const showError = vi.fn()
|
||||
const turnstileReset = vi.fn()
|
||||
const verifyTencent = vi.fn()
|
||||
const verifyAction = vi.fn()
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
@@ -44,7 +44,7 @@ describe('PendingOAuthCreateAccountForm', () => {
|
||||
getPublicSettings.mockReset()
|
||||
showError.mockReset()
|
||||
turnstileReset.mockReset()
|
||||
verifyTencent.mockReset()
|
||||
verifyAction.mockReset()
|
||||
getPublicSettings.mockResolvedValue({
|
||||
turnstile_enabled: false,
|
||||
turnstile_site_key: ''
|
||||
@@ -60,12 +60,12 @@ describe('PendingOAuthCreateAccountForm', () => {
|
||||
tencent_captcha_app_id: 'tencent-app-id'
|
||||
})
|
||||
sendPendingOAuthVerifyCode.mockResolvedValue({ countdown: 0 })
|
||||
verifyTencent
|
||||
.mockResolvedValueOnce({ ticket: 'ticket-1', randstr: '@rand-1' })
|
||||
.mockResolvedValueOnce({ ticket: 'ticket-2', randstr: '@rand-2' })
|
||||
verifyAction
|
||||
.mockResolvedValueOnce({ token: 'ticket-1', randstr: '@rand-1' })
|
||||
.mockResolvedValueOnce({ token: 'ticket-2', randstr: '@rand-2' })
|
||||
const CaptchaChallengeStub = defineComponent({
|
||||
setup(_, { expose }) {
|
||||
expose({ verifyTencent, reset: turnstileReset })
|
||||
expose({ verifyAction, reset: turnstileReset })
|
||||
return () => h('div')
|
||||
}
|
||||
})
|
||||
@@ -89,7 +89,7 @@ describe('PendingOAuthCreateAccountForm', () => {
|
||||
await wrapper.get('[data-testid="oidc-create-account-submit"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(verifyTencent).toHaveBeenCalledTimes(2)
|
||||
expect(verifyAction).toHaveBeenCalledTimes(2)
|
||||
expect(sendPendingOAuthVerifyCode).toHaveBeenCalledWith({
|
||||
email: 'user@example.com',
|
||||
tencent_captcha_ticket: 'ticket-1',
|
||||
|
||||
@@ -183,6 +183,16 @@ export default {
|
||||
secretKeyHint: 'Server-side verification key (keep this secret)',
|
||||
secretKeyConfiguredHint: 'Secret key configured. Leave empty to keep the current value.'
|
||||
},
|
||||
captcha: {
|
||||
title: 'CAPTCHA',
|
||||
description: 'Bot protection for login and registration',
|
||||
enable: 'Enable CAPTCHA',
|
||||
enableHint: 'Require human verification on login, registration and related flows',
|
||||
provider: 'Provider',
|
||||
providerTurnstile: 'Cloudflare Turnstile',
|
||||
providerTencent: 'Tencent Captcha',
|
||||
providerAliyun: 'Aliyun Captcha 2.0'
|
||||
},
|
||||
tencentCaptcha: {
|
||||
title: 'Tencent Captcha',
|
||||
description: 'Slider captcha protection for login, registration, and third-party account creation',
|
||||
@@ -191,7 +201,7 @@ export default {
|
||||
keepExisting: 'Leave empty to keep current value',
|
||||
configured: 'Configured. Leave empty to keep it.',
|
||||
required: 'Required before enabling.',
|
||||
mutualExclusion: 'Tencent Captcha and Cloudflare Turnstile are mutually exclusive. Enabling one disables the other.',
|
||||
mutualExclusion: 'Tencent Captcha, Cloudflare Turnstile and Aliyun Captcha are mutually exclusive. Enabling one disables the others.',
|
||||
appCredentialsTitle: 'Captcha application credentials',
|
||||
appCredentialsHint: 'Get CaptchaAppId and AppSecretKey from Verification Management in the Captcha console.',
|
||||
cloudCredentialsTitle: 'Cloud API credentials',
|
||||
@@ -206,6 +216,21 @@ export default {
|
||||
createCloudKeys: 'Create SecretId / SecretKey',
|
||||
openWebDocs: 'View Web integration guide'
|
||||
},
|
||||
aliyunCaptcha: {
|
||||
accessKeyId: 'AccessKey ID',
|
||||
accessKeyIdHint: 'Alibaba Cloud AccessKey ID used for server-side verification; a captcha-only RAM user is recommended',
|
||||
accessKeySecret: 'AccessKey Secret',
|
||||
accessKeySecretHint: 'Server-side verification secret (keep this secret)',
|
||||
accessKeySecretConfiguredHint: 'Secret configured. Leave empty to keep the current value.',
|
||||
sceneId: 'Scene ID',
|
||||
sceneIdHint: 'Create a verification scene in the Alibaba Cloud Captcha console; the captcha type (invisible/slider/puzzle) is configured per scene there',
|
||||
prefix: 'Prefix',
|
||||
prefixHint: 'Found in the instance information on the console overview page',
|
||||
region: 'Region',
|
||||
regionCn: 'Mainland China',
|
||||
regionSgp: 'Singapore',
|
||||
regionHint: 'Determines the frontend script region and the server endpoint; must match your captcha instance region'
|
||||
},
|
||||
apiKeyAcl: {
|
||||
title: 'API Key IP Access Control',
|
||||
description:
|
||||
|
||||
@@ -245,6 +245,8 @@ export default {
|
||||
turnstileFailed: 'Verification failed, please try again',
|
||||
captchaVerified: 'Verification completed',
|
||||
captchaLoading: 'Loading verification…',
|
||||
captchaClickToVerify: 'Click to complete verification',
|
||||
captchaVerifying: 'Verifying…',
|
||||
completeVerification: 'Please complete the verification',
|
||||
verifyYourEmail: 'Verify Your Email',
|
||||
sessionExpired: 'Session expired',
|
||||
|
||||
@@ -183,6 +183,16 @@ export default {
|
||||
secretKeyHint: '服务端验证密钥(请保密)',
|
||||
secretKeyConfiguredHint: '密钥已配置,留空以保留当前值。'
|
||||
},
|
||||
captcha: {
|
||||
title: '人机验证',
|
||||
description: '登录和注册的机器人防护',
|
||||
enable: '启用人机验证',
|
||||
enableHint: '开启后登录、注册等入口需要通过人机验证',
|
||||
provider: '验证服务商',
|
||||
providerTurnstile: 'Cloudflare Turnstile',
|
||||
providerTencent: '腾讯天御验证码',
|
||||
providerAliyun: '阿里云验证码 2.0'
|
||||
},
|
||||
tencentCaptcha: {
|
||||
title: '腾讯天御验证码',
|
||||
description: '为登录、注册及第三方登录创建账号流程提供滑动验证码保护',
|
||||
@@ -191,7 +201,7 @@ export default {
|
||||
keepExisting: '留空以保留当前值',
|
||||
configured: '已配置,留空不会覆盖。',
|
||||
required: '启用前必须填写此项。',
|
||||
mutualExclusion: '腾讯天御验证码与 Cloudflare Turnstile 互斥,开启其中一个会自动关闭另一个。',
|
||||
mutualExclusion: '腾讯天御验证码与 Cloudflare Turnstile、阿里云验证码互斥,开启其中一个会自动关闭其它。',
|
||||
appCredentialsTitle: '验证码应用密钥',
|
||||
appCredentialsHint: 'CaptchaAppId 与 AppSecretKey 来自验证码控制台的验证管理页面。',
|
||||
cloudCredentialsTitle: '云 API 调用密钥',
|
||||
@@ -206,6 +216,21 @@ export default {
|
||||
createCloudKeys: '创建 SecretId / SecretKey',
|
||||
openWebDocs: '查看 Web 接入文档'
|
||||
},
|
||||
aliyunCaptcha: {
|
||||
accessKeyId: 'AccessKey ID',
|
||||
accessKeyIdHint: '用于服务端验证的阿里云 AccessKey ID,建议使用仅含验证码权限的 RAM 子账号',
|
||||
accessKeySecret: 'AccessKey Secret',
|
||||
accessKeySecretHint: '服务端验证密钥(请保密)',
|
||||
accessKeySecretConfiguredHint: '密钥已配置,留空以保留当前值。',
|
||||
sceneId: '场景 ID',
|
||||
sceneIdHint: '在阿里云验证码控制台创建验证场景后获取;验证方式(无痕/滑块/拼图)在控制台按场景配置',
|
||||
prefix: '身份标(prefix)',
|
||||
prefixHint: '在控制台概览页实例基本信息中获取',
|
||||
region: '服务地域',
|
||||
regionCn: '中国内地',
|
||||
regionSgp: '新加坡',
|
||||
regionHint: '决定前端脚本接入区域与服务端接入点,需与阿里云验证码实例所属地域一致'
|
||||
},
|
||||
apiKeyAcl: {
|
||||
title: 'API Key IP 访问控制',
|
||||
description: '控制 API Key 白/黑名单、操作审计日志与会话 IP/UA 绑定使用哪个客户端 IP 判断',
|
||||
|
||||
@@ -244,6 +244,8 @@ export default {
|
||||
turnstileFailed: '验证失败,请重试',
|
||||
captchaVerified: '验证已完成',
|
||||
captchaLoading: '正在加载验证码…',
|
||||
captchaClickToVerify: '点击完成人机验证',
|
||||
captchaVerifying: '验证中…',
|
||||
completeVerification: '请完成验证',
|
||||
verifyYourEmail: '验证您的邮箱',
|
||||
sessionExpired: '会话已过期',
|
||||
|
||||
@@ -335,6 +335,10 @@ export const useAppStore = defineStore('app', () => {
|
||||
invitation_code_enabled: false,
|
||||
turnstile_enabled: false,
|
||||
turnstile_site_key: '',
|
||||
aliyun_captcha_enabled: false,
|
||||
aliyun_captcha_scene_id: '',
|
||||
aliyun_captcha_prefix: '',
|
||||
aliyun_captcha_region: 'cn',
|
||||
site_name: siteName.value,
|
||||
site_logo: siteLogo.value,
|
||||
site_subtitle: '',
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
AuthResponse,
|
||||
TencentCaptchaRequestProof
|
||||
ActionCaptchaRequestProof
|
||||
} from '@/types'
|
||||
|
||||
const AUTH_TOKEN_KEY = 'auth_token'
|
||||
@@ -281,7 +281,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function loginWithPasskey(proof?: TencentCaptchaRequestProof): Promise<User> {
|
||||
async function loginWithPasskey(proof?: ActionCaptchaRequestProof): Promise<User> {
|
||||
try {
|
||||
const response = await passkeyAPI.login(proof)
|
||||
setAuthFromResponse(response)
|
||||
|
||||
@@ -124,6 +124,12 @@ export interface TencentCaptchaRequestProof {
|
||||
tencent_captcha_randstr: string
|
||||
}
|
||||
|
||||
// 动作触发式验证码(OAuth 启动、passkey 等入口)的请求凭据:
|
||||
// 腾讯填 tencent_captcha_*,阿里云的 captchaVerifyParam 复用 turnstile_token 字段
|
||||
export interface ActionCaptchaRequestProof extends Partial<TencentCaptchaRequestProof> {
|
||||
turnstile_token?: string
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
email: string
|
||||
password: string
|
||||
@@ -216,6 +222,10 @@ export interface PublicSettings {
|
||||
tencent_captcha_app_id?: string
|
||||
passkey_enabled?: boolean
|
||||
turnstile_site_key: string
|
||||
aliyun_captcha_enabled?: boolean
|
||||
aliyun_captcha_scene_id?: string
|
||||
aliyun_captcha_prefix?: string
|
||||
aliyun_captcha_region?: string
|
||||
site_name: string
|
||||
site_logo: string
|
||||
site_subtitle: string
|
||||
|
||||
@@ -1967,42 +1967,97 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cloudflare Turnstile Settings -->
|
||||
<!-- 人机验证 Settings -->
|
||||
<div class="card">
|
||||
<div
|
||||
class="border-b border-gray-100 px-6 py-4 dark:border-dark-700"
|
||||
>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t("admin.settings.turnstile.title") }}
|
||||
{{ t("admin.settings.captcha.title") }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.turnstile.description") }}
|
||||
{{ t("admin.settings.captcha.description") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-5 p-6">
|
||||
<!-- Enable Turnstile -->
|
||||
<!-- Enable Captcha -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{
|
||||
t("admin.settings.turnstile.enableTurnstile")
|
||||
t("admin.settings.captcha.enable")
|
||||
}}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.turnstile.enableTurnstileHint") }}
|
||||
{{ t("admin.settings.captcha.enableHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
v-model="form.turnstile_enabled"
|
||||
data-testid="turnstile-enabled-toggle"
|
||||
@update:model-value="onTurnstileToggle"
|
||||
v-model="captchaMasterEnabled"
|
||||
data-testid="captcha-enabled-toggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Turnstile Keys - Only show when enabled -->
|
||||
<!-- Provider fields - Only show when enabled -->
|
||||
<div
|
||||
v-if="form.turnstile_enabled"
|
||||
v-if="captchaMasterEnabled"
|
||||
class="border-t border-gray-100 pt-4 dark:border-dark-700"
|
||||
>
|
||||
<div class="grid grid-cols-1 gap-6">
|
||||
<!-- Provider Selector -->
|
||||
<div class="mb-6">
|
||||
<label
|
||||
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ t("admin.settings.captcha.provider") }}
|
||||
</label>
|
||||
<div
|
||||
class="grid grid-cols-3 gap-2 rounded-lg bg-gray-100 p-1 dark:bg-dark-700"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="captcha-provider-turnstile"
|
||||
class="inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition"
|
||||
:class="
|
||||
captchaProviderSelection === 'turnstile'
|
||||
? 'bg-white text-primary-700 shadow-sm dark:bg-dark-800 dark:text-primary-300'
|
||||
: 'text-gray-600 hover:text-gray-900 dark:text-dark-300 dark:hover:text-white'
|
||||
"
|
||||
@click="selectCaptchaProvider('turnstile')"
|
||||
>
|
||||
{{ t("admin.settings.captcha.providerTurnstile") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="captcha-provider-tencent"
|
||||
class="inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition"
|
||||
:class="
|
||||
captchaProviderSelection === 'tencent'
|
||||
? 'bg-white text-primary-700 shadow-sm dark:bg-dark-800 dark:text-primary-300'
|
||||
: 'text-gray-600 hover:text-gray-900 dark:text-dark-300 dark:hover:text-white'
|
||||
"
|
||||
@click="selectCaptchaProvider('tencent')"
|
||||
>
|
||||
{{ t("admin.settings.captcha.providerTencent") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="captcha-provider-aliyun"
|
||||
class="inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition"
|
||||
:class="
|
||||
captchaProviderSelection === 'aliyun'
|
||||
? 'bg-white text-primary-700 shadow-sm dark:bg-dark-800 dark:text-primary-300'
|
||||
: 'text-gray-600 hover:text-gray-900 dark:text-dark-300 dark:hover:text-white'
|
||||
"
|
||||
@click="selectCaptchaProvider('aliyun')"
|
||||
>
|
||||
{{ t("admin.settings.captcha.providerAliyun") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cloudflare Turnstile fields -->
|
||||
<div
|
||||
v-if="captchaProviderSelection === 'turnstile'"
|
||||
class="grid grid-cols-1 gap-6"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
@@ -2050,151 +2105,232 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 腾讯天御验证码设置 -->
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t("admin.settings.tencentCaptcha.title") }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.tencentCaptcha.description") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-5 p-6">
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">
|
||||
{{ t("admin.settings.tencentCaptcha.enable") }}
|
||||
</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.tencentCaptcha.enableHint") }}
|
||||
<!-- Tencent Captcha fields -->
|
||||
<div v-else-if="captchaProviderSelection === 'tencent'">
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div class="md:col-span-2">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t("admin.settings.tencentCaptcha.appCredentialsTitle") }}
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.tencentCaptcha.appCredentialsHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t("admin.settings.tencentCaptcha.appId") }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.tencent_captcha_app_id"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
class="input font-mono text-sm"
|
||||
placeholder="123456789"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t("admin.settings.tencentCaptcha.appSecretKey") }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.tencent_captcha_app_secret_key"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.tencentCaptcha.keepExisting')"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ form.tencent_captcha_app_secret_key_configured ? t("admin.settings.tencentCaptcha.configured") : t("admin.settings.tencentCaptcha.required") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-t border-gray-100 pt-5 md:col-span-2 dark:border-dark-700">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t("admin.settings.tencentCaptcha.cloudCredentialsTitle") }}
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.tencentCaptcha.cloudCredentialsHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t("admin.settings.tencentCaptcha.cloudSecretId") }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.tencent_captcha_cloud_secret_id"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.tencentCaptcha.keepExisting')"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ form.tencent_captcha_cloud_secret_id_configured ? t("admin.settings.tencentCaptcha.configured") : t("admin.settings.tencentCaptcha.required") }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t("admin.settings.tencentCaptcha.cloudSecretKey") }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.tencent_captcha_cloud_secret_key"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.tencentCaptcha.keepExisting')"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ form.tencent_captcha_cloud_secret_key_configured ? t("admin.settings.tencentCaptcha.configured") : t("admin.settings.tencentCaptcha.required") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.tencentCaptcha.camPermissionHint") }}
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.tencentCaptcha.aidEncryptedHint") }}
|
||||
</p>
|
||||
<div class="mt-3 flex flex-wrap gap-x-4 gap-y-2 text-sm">
|
||||
<a
|
||||
href="https://console.cloud.tencent.com/captcha"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
{{ t("admin.settings.tencentCaptcha.openCaptchaConsole") }}
|
||||
</a>
|
||||
<a
|
||||
href="https://console.cloud.tencent.com/cam/capi"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
{{ t("admin.settings.tencentCaptcha.createCloudKeys") }}
|
||||
</a>
|
||||
<a
|
||||
href="https://cloud.tencent.com/document/product/1110/36841"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
{{ t("admin.settings.tencentCaptcha.openWebDocs") }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<Toggle
|
||||
v-model="form.tencent_captcha_enabled"
|
||||
data-testid="tencent-captcha-enabled-toggle"
|
||||
@update:model-value="onTencentCaptchaToggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="form.tencent_captcha_enabled"
|
||||
class="border-t border-gray-100 pt-4 dark:border-dark-700"
|
||||
>
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div class="md:col-span-2">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t("admin.settings.tencentCaptcha.appCredentialsTitle") }}
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.tencentCaptcha.appCredentialsHint") }}
|
||||
</p>
|
||||
<!-- Aliyun Captcha 2.0 fields -->
|
||||
<div v-else class="grid grid-cols-1 gap-6">
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<label
|
||||
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ t("admin.settings.aliyunCaptcha.region") }}
|
||||
</label>
|
||||
<div
|
||||
class="grid grid-cols-2 gap-2 rounded-lg bg-gray-100 p-1 dark:bg-dark-700"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-md px-3 py-1.5 text-sm font-medium transition"
|
||||
:class="
|
||||
form.aliyun_captcha_region !== 'sgp'
|
||||
? 'bg-white text-primary-700 shadow-sm dark:bg-dark-800 dark:text-primary-300'
|
||||
: 'text-gray-600 hover:text-gray-900 dark:text-dark-300 dark:hover:text-white'
|
||||
"
|
||||
@click="form.aliyun_captcha_region = 'cn'"
|
||||
>
|
||||
{{ t("admin.settings.aliyunCaptcha.regionCn") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-md px-3 py-1.5 text-sm font-medium transition"
|
||||
:class="
|
||||
form.aliyun_captcha_region === 'sgp'
|
||||
? 'bg-white text-primary-700 shadow-sm dark:bg-dark-800 dark:text-primary-300'
|
||||
: 'text-gray-600 hover:text-gray-900 dark:text-dark-300 dark:hover:text-white'
|
||||
"
|
||||
@click="form.aliyun_captcha_region = 'sgp'"
|
||||
>
|
||||
{{ t("admin.settings.aliyunCaptcha.regionSgp") }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.aliyunCaptcha.regionHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ t("admin.settings.aliyunCaptcha.prefix") }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.aliyun_captcha_prefix"
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
placeholder="14xxxxx"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.aliyunCaptcha.prefixHint") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t("admin.settings.tencentCaptcha.appId") }}
|
||||
<label
|
||||
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ t("admin.settings.aliyunCaptcha.sceneId") }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.tencent_captcha_app_id"
|
||||
v-model="form.aliyun_captcha_scene_id"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
class="input font-mono text-sm"
|
||||
placeholder="123456789"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t("admin.settings.tencentCaptcha.appSecretKey") }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.tencent_captcha_app_secret_key"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.tencentCaptcha.keepExisting')"
|
||||
placeholder="1cxxxxxx"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ form.tencent_captcha_app_secret_key_configured ? t("admin.settings.tencentCaptcha.configured") : t("admin.settings.tencentCaptcha.required") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-t border-gray-100 pt-5 md:col-span-2 dark:border-dark-700">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t("admin.settings.tencentCaptcha.cloudCredentialsTitle") }}
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.tencentCaptcha.cloudCredentialsHint") }}
|
||||
{{ t("admin.settings.aliyunCaptcha.sceneIdHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t("admin.settings.tencentCaptcha.cloudSecretId") }}
|
||||
<label
|
||||
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ t("admin.settings.aliyunCaptcha.accessKeyId") }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.tencent_captcha_cloud_secret_id"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
v-model="form.aliyun_captcha_access_key_id"
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.tencentCaptcha.keepExisting')"
|
||||
placeholder="LTAI..."
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ form.tencent_captcha_cloud_secret_id_configured ? t("admin.settings.tencentCaptcha.configured") : t("admin.settings.tencentCaptcha.required") }}
|
||||
{{ t("admin.settings.aliyunCaptcha.accessKeyIdHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t("admin.settings.tencentCaptcha.cloudSecretKey") }}
|
||||
<label
|
||||
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ t("admin.settings.aliyunCaptcha.accessKeySecret") }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.tencent_captcha_cloud_secret_key"
|
||||
v-model="form.aliyun_captcha_access_key_secret"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.tencentCaptcha.keepExisting')"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ form.tencent_captcha_cloud_secret_key_configured ? t("admin.settings.tencentCaptcha.configured") : t("admin.settings.tencentCaptcha.required") }}
|
||||
{{
|
||||
form.aliyun_captcha_access_key_secret_configured
|
||||
? t(
|
||||
"admin.settings.aliyunCaptcha.accessKeySecretConfiguredHint",
|
||||
)
|
||||
: t("admin.settings.aliyunCaptcha.accessKeySecretHint")
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-5 text-xs text-amber-600 dark:text-amber-400">
|
||||
{{ t("admin.settings.tencentCaptcha.mutualExclusion") }}
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.tencentCaptcha.camPermissionHint") }}
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.tencentCaptcha.aidEncryptedHint") }}
|
||||
</p>
|
||||
<div class="mt-3 flex flex-wrap gap-x-4 gap-y-2 text-sm">
|
||||
<a
|
||||
href="https://console.cloud.tencent.com/captcha"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
{{ t("admin.settings.tencentCaptcha.openCaptchaConsole") }}
|
||||
</a>
|
||||
<a
|
||||
href="https://console.cloud.tencent.com/cam/capi"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
{{ t("admin.settings.tencentCaptcha.createCloudKeys") }}
|
||||
</a>
|
||||
<a
|
||||
href="https://cloud.tencent.com/document/product/1110/36841"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
{{ t("admin.settings.tencentCaptcha.openWebDocs") }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -8990,6 +9126,7 @@ type SettingsForm = Omit<
|
||||
tencent_captcha_app_secret_key: string;
|
||||
tencent_captcha_cloud_secret_id: string;
|
||||
tencent_captcha_cloud_secret_key: string;
|
||||
aliyun_captcha_access_key_secret: string;
|
||||
linuxdo_connect_client_secret: string;
|
||||
dingtalk_connect_client_secret: string;
|
||||
wechat_connect_app_secret: string;
|
||||
@@ -9127,6 +9264,13 @@ const form = reactive<SettingsForm>({
|
||||
tencent_captcha_cloud_secret_id_configured: false,
|
||||
tencent_captcha_cloud_secret_key: "",
|
||||
tencent_captcha_cloud_secret_key_configured: false,
|
||||
aliyun_captcha_enabled: false,
|
||||
aliyun_captcha_access_key_id: "",
|
||||
aliyun_captcha_access_key_secret: "",
|
||||
aliyun_captcha_access_key_secret_configured: false,
|
||||
aliyun_captcha_scene_id: "",
|
||||
aliyun_captcha_prefix: "",
|
||||
aliyun_captcha_region: "cn",
|
||||
api_key_acl_trust_forwarded_ip: true,
|
||||
forwarded_client_ip_headers: [],
|
||||
// LinuxDo Connect OAuth 登录
|
||||
@@ -9290,12 +9434,40 @@ const form = reactive<SettingsForm>({
|
||||
allow_user_view_error_requests: false,
|
||||
});
|
||||
|
||||
function onTurnstileToggle(enabled: boolean): void {
|
||||
if (enabled) form.tencent_captcha_enabled = false;
|
||||
// 人机验证 UI 状态:单卡片「总开关 + 服务商单选」,落库仍是三个独立
|
||||
// enabled 键(与上游一致),由下面的映射保证同一时间至多一家启用。
|
||||
type CaptchaProviderSelection = "turnstile" | "tencent" | "aliyun";
|
||||
|
||||
const captchaProviderSelection = ref<CaptchaProviderSelection>("turnstile");
|
||||
|
||||
function applyCaptchaSelection(provider: CaptchaProviderSelection | null): void {
|
||||
form.turnstile_enabled = provider === "turnstile";
|
||||
form.tencent_captcha_enabled = provider === "tencent";
|
||||
form.aliyun_captcha_enabled = provider === "aliyun";
|
||||
}
|
||||
|
||||
function onTencentCaptchaToggle(enabled: boolean): void {
|
||||
if (enabled) form.turnstile_enabled = false;
|
||||
const captchaMasterEnabled = computed({
|
||||
get: () =>
|
||||
form.turnstile_enabled ||
|
||||
form.tencent_captcha_enabled ||
|
||||
form.aliyun_captcha_enabled,
|
||||
set: (enabled: boolean) =>
|
||||
applyCaptchaSelection(enabled ? captchaProviderSelection.value : null),
|
||||
});
|
||||
|
||||
function selectCaptchaProvider(provider: CaptchaProviderSelection): void {
|
||||
captchaProviderSelection.value = provider;
|
||||
applyCaptchaSelection(provider);
|
||||
}
|
||||
|
||||
function syncCaptchaProviderSelection(): void {
|
||||
if (form.tencent_captcha_enabled) {
|
||||
captchaProviderSelection.value = "tencent";
|
||||
} else if (form.aliyun_captcha_enabled) {
|
||||
captchaProviderSelection.value = "aliyun";
|
||||
} else if (form.turnstile_enabled) {
|
||||
captchaProviderSelection.value = "turnstile";
|
||||
}
|
||||
}
|
||||
|
||||
type OpenAIAdvancedSchedulerOverrideKey =
|
||||
@@ -10209,6 +10381,7 @@ async function loadSettings() {
|
||||
(form as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
}
|
||||
syncCaptchaProviderSelection();
|
||||
if (!form.claude_oauth_system_prompt_blocks?.trim()) {
|
||||
form.claude_oauth_system_prompt_blocks =
|
||||
defaultClaudeOAuthSystemPromptBlocks;
|
||||
@@ -10266,6 +10439,7 @@ async function loadSettings() {
|
||||
form.tencent_captcha_app_secret_key = "";
|
||||
form.tencent_captcha_cloud_secret_id = "";
|
||||
form.tencent_captcha_cloud_secret_key = "";
|
||||
form.aliyun_captcha_access_key_secret = "";
|
||||
form.linuxdo_connect_client_secret = "";
|
||||
form.dingtalk_connect_client_secret = "";
|
||||
form.github_oauth_client_secret = "";
|
||||
@@ -10643,6 +10817,13 @@ async function saveSettings() {
|
||||
form.tencent_captcha_cloud_secret_id || undefined,
|
||||
tencent_captcha_cloud_secret_key:
|
||||
form.tencent_captcha_cloud_secret_key || undefined,
|
||||
aliyun_captcha_enabled: form.aliyun_captcha_enabled,
|
||||
aliyun_captcha_access_key_id: form.aliyun_captcha_access_key_id,
|
||||
aliyun_captcha_access_key_secret:
|
||||
form.aliyun_captcha_access_key_secret || undefined,
|
||||
aliyun_captcha_scene_id: form.aliyun_captcha_scene_id,
|
||||
aliyun_captcha_prefix: form.aliyun_captcha_prefix,
|
||||
aliyun_captcha_region: form.aliyun_captcha_region,
|
||||
api_key_acl_trust_forwarded_ip: form.api_key_acl_trust_forwarded_ip,
|
||||
forwarded_client_ip_headers: form.forwarded_client_ip_headers,
|
||||
linuxdo_connect_enabled: form.linuxdo_connect_enabled,
|
||||
@@ -10933,6 +11114,7 @@ async function saveSettings() {
|
||||
form.smtp_password = "";
|
||||
smtpPasswordManuallyEdited.value = false;
|
||||
form.turnstile_secret_key = "";
|
||||
form.aliyun_captcha_access_key_secret = "";
|
||||
form.linuxdo_connect_client_secret = "";
|
||||
form.dingtalk_connect_client_secret = "";
|
||||
form.github_oauth_client_secret = "";
|
||||
|
||||
@@ -772,24 +772,24 @@ describe("admin SettingsView payment visible method controls", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("腾讯天御验证码与 Turnstile 开关互斥并保存四项配置", async () => {
|
||||
it("人机验证切换到腾讯天御并保存四项配置", async () => {
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
await openSecurityTab(wrapper);
|
||||
|
||||
const turnstileToggle = wrapper.get('[data-testid="turnstile-enabled-toggle"]');
|
||||
const tencentToggle = wrapper.get('[data-testid="tencent-captcha-enabled-toggle"]');
|
||||
await turnstileToggle.setValue(true);
|
||||
expect((turnstileToggle.element as HTMLInputElement).checked).toBe(true);
|
||||
const masterToggle = wrapper.get('[data-testid="captcha-enabled-toggle"]');
|
||||
await masterToggle.setValue(true);
|
||||
// 默认选中 Turnstile
|
||||
expect(wrapper.text()).toContain("admin.settings.turnstile.siteKey");
|
||||
|
||||
await tencentToggle.setValue(true);
|
||||
expect((turnstileToggle.element as HTMLInputElement).checked).toBe(false);
|
||||
expect((tencentToggle.element as HTMLInputElement).checked).toBe(true);
|
||||
await wrapper.get('[data-testid="captcha-provider-tencent"]').trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
const card = wrapper
|
||||
.findAll(".card")
|
||||
.find((node) => node.text().includes("admin.settings.tencentCaptcha.title"));
|
||||
.find((node) => node.text().includes("admin.settings.captcha.title"));
|
||||
expect(card).toBeDefined();
|
||||
expect(card!.text()).not.toContain("admin.settings.turnstile.siteKey");
|
||||
expect(card!.get('a[href="https://console.cloud.tencent.com/captcha"]').exists()).toBe(true);
|
||||
expect(card!.get('a[href="https://console.cloud.tencent.com/cam/capi"]').exists()).toBe(true);
|
||||
expect(
|
||||
@@ -808,6 +808,7 @@ describe("admin SettingsView payment visible method controls", () => {
|
||||
expect.objectContaining({
|
||||
turnstile_enabled: false,
|
||||
tencent_captcha_enabled: true,
|
||||
aliyun_captcha_enabled: false,
|
||||
tencent_captcha_app_id: "123456789",
|
||||
tencent_captcha_app_secret_key: "app-secret-value",
|
||||
tencent_captcha_cloud_secret_id: "cloud-secret-id-value",
|
||||
@@ -816,6 +817,77 @@ describe("admin SettingsView payment visible method controls", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("人机验证切换到阿里云并保存配置", async () => {
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
await openSecurityTab(wrapper);
|
||||
|
||||
const masterToggle = wrapper.get('[data-testid="captcha-enabled-toggle"]');
|
||||
await masterToggle.setValue(true);
|
||||
|
||||
await wrapper.get('[data-testid="captcha-provider-aliyun"]').trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
const card = wrapper
|
||||
.findAll(".card")
|
||||
.find((node) => node.text().includes("admin.settings.captcha.title"));
|
||||
expect(card).toBeDefined();
|
||||
expect(card!.text()).toContain("admin.settings.aliyunCaptcha.region");
|
||||
expect(card!.text()).not.toContain("admin.settings.turnstile.siteKey");
|
||||
const inputs = card!.findAll("input").filter((input) => input.attributes("type") !== "checkbox");
|
||||
await inputs[0]!.setValue("prefix-1");
|
||||
await inputs[1]!.setValue("scene-1");
|
||||
await inputs[2]!.setValue("ak-id");
|
||||
await inputs[3]!.setValue("ak-secret-value");
|
||||
|
||||
await wrapper.find("form").trigger("submit.prevent");
|
||||
await flushPromises();
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
turnstile_enabled: false,
|
||||
tencent_captcha_enabled: false,
|
||||
aliyun_captcha_enabled: true,
|
||||
aliyun_captcha_prefix: "prefix-1",
|
||||
aliyun_captcha_scene_id: "scene-1",
|
||||
aliyun_captcha_access_key_id: "ak-id",
|
||||
aliyun_captcha_access_key_secret: "ak-secret-value",
|
||||
aliyun_captcha_region: "cn",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("关闭人机验证总开关会同时关闭所有服务商", async () => {
|
||||
getSettings.mockResolvedValueOnce({
|
||||
...baseSettingsResponse,
|
||||
tencent_captcha_enabled: true,
|
||||
tencent_captcha_app_id: "123456789",
|
||||
tencent_captcha_app_secret_key_configured: true,
|
||||
tencent_captcha_cloud_secret_id_configured: true,
|
||||
tencent_captcha_cloud_secret_key_configured: true,
|
||||
});
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
await openSecurityTab(wrapper);
|
||||
|
||||
const masterToggle = wrapper.get('[data-testid="captcha-enabled-toggle"]');
|
||||
expect((masterToggle.element as HTMLInputElement).checked).toBe(true);
|
||||
// 加载后选中项跟随已启用的服务商
|
||||
expect(wrapper.text()).toContain("admin.settings.tencentCaptcha.appId");
|
||||
|
||||
await masterToggle.setValue(false);
|
||||
await wrapper.find("form").trigger("submit.prevent");
|
||||
await flushPromises();
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
turnstile_enabled: false,
|
||||
tencent_captcha_enabled: false,
|
||||
aliyun_captcha_enabled: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("disables passkey sign-in when the RP configuration is unavailable", async () => {
|
||||
getSettings.mockResolvedValueOnce({
|
||||
...baseSettingsResponse,
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Turnstile Widget for Resend -->
|
||||
<div v-if="tencentCaptchaEnabled || (turnstileEnabled && showResendTurnstile)">
|
||||
<div v-if="actionCaptchaEnabled || (turnstileEnabled && showResendTurnstile)">
|
||||
<TurnstileWidget
|
||||
ref="turnstileRef"
|
||||
:site-key="turnstileSiteKey"
|
||||
@@ -75,6 +75,10 @@
|
||||
:turnstile-site-key="turnstileSiteKey"
|
||||
:tencent-enabled="tencentCaptchaEnabled"
|
||||
:tencent-app-id="tencentCaptchaAppId"
|
||||
:aliyun-enabled="aliyunCaptchaEnabled"
|
||||
:aliyun-scene-id="aliyunCaptchaSceneId"
|
||||
:aliyun-prefix="aliyunCaptchaPrefix"
|
||||
:aliyun-region="aliyunCaptchaRegion"
|
||||
@verify="onTurnstileVerify"
|
||||
@expire="onTurnstileExpire"
|
||||
@error="onTurnstileError"
|
||||
@@ -89,6 +93,10 @@
|
||||
:turnstile-site-key="turnstileSiteKey"
|
||||
:tencent-enabled="tencentCaptchaEnabled"
|
||||
:tencent-app-id="tencentCaptchaAppId"
|
||||
:aliyun-enabled="aliyunCaptchaEnabled"
|
||||
:aliyun-scene-id="aliyunCaptchaSceneId"
|
||||
:aliyun-prefix="aliyunCaptchaPrefix"
|
||||
:aliyun-region="aliyunCaptchaRegion"
|
||||
@verify="onCreateAccountTurnstileVerify"
|
||||
@expire="onCreateAccountTurnstileExpire"
|
||||
@error="onCreateAccountTurnstileError"
|
||||
@@ -254,6 +262,10 @@ const turnstileEnabled = ref<boolean>(false)
|
||||
const turnstileSiteKey = ref<string>('')
|
||||
const tencentCaptchaEnabled = ref<boolean>(false)
|
||||
const tencentCaptchaAppId = ref<string>('')
|
||||
const aliyunCaptchaEnabled = ref<boolean>(false)
|
||||
const aliyunCaptchaSceneId = ref<string>('')
|
||||
const aliyunCaptchaPrefix = ref<string>('')
|
||||
const aliyunCaptchaRegion = ref<string>('cn')
|
||||
const siteName = ref<string>('Sub2API')
|
||||
const registrationEmailSuffixWhitelist = ref<string[]>([])
|
||||
|
||||
@@ -265,10 +277,21 @@ const resendTencentCaptchaRandstr = ref<string>('')
|
||||
const createAccountTurnstileToken = ref<string>('')
|
||||
const createAccountTencentCaptchaRandstr = ref<string>('')
|
||||
const showResendTurnstile = ref<boolean>(false)
|
||||
const aliyunCaptchaReady = computed(
|
||||
() =>
|
||||
aliyunCaptchaEnabled.value &&
|
||||
Boolean(aliyunCaptchaSceneId.value) &&
|
||||
Boolean(aliyunCaptchaPrefix.value)
|
||||
)
|
||||
// 动作触发式验证码(腾讯/阿里云):重发验证码、创建账号时弹窗验证
|
||||
const actionCaptchaEnabled = computed(
|
||||
() =>
|
||||
(tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value)) ||
|
||||
aliyunCaptchaReady.value
|
||||
)
|
||||
const captchaEnabled = computed(
|
||||
() =>
|
||||
(turnstileEnabled.value && Boolean(turnstileSiteKey.value)) ||
|
||||
(tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value))
|
||||
(turnstileEnabled.value && Boolean(turnstileSiteKey.value)) || actionCaptchaEnabled.value
|
||||
)
|
||||
|
||||
const errors = ref({
|
||||
@@ -338,6 +361,10 @@ onMounted(async () => {
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||
tencentCaptchaEnabled.value = settings.tencent_captcha_enabled === true
|
||||
tencentCaptchaAppId.value = settings.tencent_captcha_app_id || ''
|
||||
aliyunCaptchaEnabled.value = settings.aliyun_captcha_enabled === true
|
||||
aliyunCaptchaSceneId.value = settings.aliyun_captcha_scene_id || ''
|
||||
aliyunCaptchaPrefix.value = settings.aliyun_captcha_prefix || ''
|
||||
aliyunCaptchaRegion.value = settings.aliyun_captcha_region || 'cn'
|
||||
siteName.value = settings.site_name || 'Sub2API'
|
||||
registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist(
|
||||
settings.registration_email_suffix_whitelist || []
|
||||
@@ -424,24 +451,24 @@ function resetCreateAccountTurnstile(): void {
|
||||
createAccountTurnstileRef.value?.reset()
|
||||
}
|
||||
|
||||
async function acquireResendTencentProof(): Promise<boolean> {
|
||||
if (!tencentCaptchaEnabled.value) return true
|
||||
async function acquireResendActionProof(): Promise<boolean> {
|
||||
if (!actionCaptchaEnabled.value) return true
|
||||
|
||||
const proof = await turnstileRef.value?.verifyTencent()
|
||||
const proof = await turnstileRef.value?.verifyAction()
|
||||
if (!proof) return false
|
||||
|
||||
resendTurnstileToken.value = proof.ticket
|
||||
resendTurnstileToken.value = proof.token
|
||||
resendTencentCaptchaRandstr.value = proof.randstr
|
||||
return true
|
||||
}
|
||||
|
||||
async function acquireCreateAccountTencentProof(): Promise<boolean> {
|
||||
if (!isPendingOAuthFlow() || !tencentCaptchaEnabled.value) return true
|
||||
async function acquireCreateAccountActionProof(): Promise<boolean> {
|
||||
if (!isPendingOAuthFlow() || !actionCaptchaEnabled.value) return true
|
||||
|
||||
const proof = await createAccountTurnstileRef.value?.verifyTencent()
|
||||
const proof = await createAccountTurnstileRef.value?.verifyAction()
|
||||
if (!proof) return false
|
||||
|
||||
createAccountTurnstileToken.value = proof.ticket
|
||||
createAccountTurnstileToken.value = proof.token
|
||||
createAccountTencentCaptchaRandstr.value = proof.randstr
|
||||
return true
|
||||
}
|
||||
@@ -505,9 +532,10 @@ async function sendCode(): Promise<void> {
|
||||
email: email.value,
|
||||
[pendingAuthTokenField.value]: pendingAuthToken.value || undefined,
|
||||
// 优先使用重发时新获取的 token(因为初始 token 可能已被使用)
|
||||
turnstile_token: turnstileEnabled.value
|
||||
? resendTurnstileToken.value || initialTurnstileToken.value || undefined
|
||||
: undefined,
|
||||
turnstile_token:
|
||||
turnstileEnabled.value || aliyunCaptchaEnabled.value
|
||||
? resendTurnstileToken.value || initialTurnstileToken.value || undefined
|
||||
: undefined,
|
||||
tencent_captcha_ticket: tencentCaptchaEnabled.value
|
||||
? resendTurnstileToken.value || initialTurnstileToken.value || undefined
|
||||
: undefined,
|
||||
@@ -593,7 +621,7 @@ async function handleResendCode(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
if (!(await acquireResendTencentProof())) {
|
||||
if (!(await acquireResendActionProof())) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -629,7 +657,7 @@ async function handleVerify(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
if (!(await acquireCreateAccountTencentProof())) {
|
||||
if (!(await acquireCreateAccountActionProof())) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -641,7 +669,8 @@ async function handleVerify(): Promise<void> {
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
verify_code: verifyCode.value.trim(),
|
||||
...(turnstileEnabled.value && createAccountTurnstileToken.value
|
||||
...((turnstileEnabled.value || aliyunCaptchaEnabled.value) &&
|
||||
createAccountTurnstileToken.value
|
||||
? { turnstile_token: createAccountTurnstileToken.value }
|
||||
: {}),
|
||||
...(tencentCaptchaEnabled.value && createAccountTurnstileToken.value
|
||||
@@ -685,7 +714,10 @@ async function handleVerify(): Promise<void> {
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
verify_code: verifyCode.value.trim(),
|
||||
turnstile_token: turnstileEnabled.value ? initialTurnstileToken.value || undefined : undefined,
|
||||
turnstile_token:
|
||||
turnstileEnabled.value || aliyunCaptchaEnabled.value
|
||||
? initialTurnstileToken.value || undefined
|
||||
: undefined,
|
||||
tencent_captcha_ticket: tencentCaptchaEnabled.value ? initialTurnstileToken.value || undefined : undefined,
|
||||
tencent_captcha_randstr: tencentCaptchaEnabled.value ? initialTencentCaptchaRandstr.value || undefined : undefined,
|
||||
promo_code: promoCode.value || undefined,
|
||||
|
||||
@@ -74,6 +74,10 @@
|
||||
:turnstile-site-key="turnstileSiteKey"
|
||||
:tencent-enabled="tencentCaptchaEnabled"
|
||||
:tencent-app-id="tencentCaptchaAppId"
|
||||
:aliyun-enabled="aliyunCaptchaEnabled"
|
||||
:aliyun-scene-id="aliyunCaptchaSceneId"
|
||||
:aliyun-prefix="aliyunCaptchaPrefix"
|
||||
:aliyun-region="aliyunCaptchaRegion"
|
||||
@verify="onTurnstileVerify"
|
||||
@expire="onTurnstileExpire"
|
||||
@error="onTurnstileError"
|
||||
@@ -153,15 +157,30 @@ const turnstileEnabled = ref<boolean>(false)
|
||||
const turnstileSiteKey = ref<string>('')
|
||||
const tencentCaptchaEnabled = ref<boolean>(false)
|
||||
const tencentCaptchaAppId = ref<string>('')
|
||||
const aliyunCaptchaEnabled = ref<boolean>(false)
|
||||
const aliyunCaptchaSceneId = ref<string>('')
|
||||
const aliyunCaptchaPrefix = ref<string>('')
|
||||
const aliyunCaptchaRegion = ref<string>('cn')
|
||||
|
||||
// Turnstile
|
||||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||||
const turnstileToken = ref<string>('')
|
||||
const tencentCaptchaRandstr = ref<string>('')
|
||||
const aliyunCaptchaReady = computed(
|
||||
() =>
|
||||
aliyunCaptchaEnabled.value &&
|
||||
Boolean(aliyunCaptchaSceneId.value) &&
|
||||
Boolean(aliyunCaptchaPrefix.value)
|
||||
)
|
||||
// 动作触发式验证码(腾讯/阿里云):提交时弹窗验证
|
||||
const actionCaptchaEnabled = computed(
|
||||
() =>
|
||||
(tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value)) ||
|
||||
aliyunCaptchaReady.value
|
||||
)
|
||||
const captchaEnabled = computed(
|
||||
() =>
|
||||
(turnstileEnabled.value && Boolean(turnstileSiteKey.value)) ||
|
||||
(tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value))
|
||||
(turnstileEnabled.value && Boolean(turnstileSiteKey.value)) || actionCaptchaEnabled.value
|
||||
)
|
||||
|
||||
const formData = reactive({
|
||||
@@ -190,6 +209,10 @@ onMounted(async () => {
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||
tencentCaptchaEnabled.value = settings.tencent_captcha_enabled === true
|
||||
tencentCaptchaAppId.value = settings.tencent_captcha_app_id || ''
|
||||
aliyunCaptchaEnabled.value = settings.aliyun_captcha_enabled === true
|
||||
aliyunCaptchaSceneId.value = settings.aliyun_captcha_scene_id || ''
|
||||
aliyunCaptchaPrefix.value = settings.aliyun_captcha_prefix || ''
|
||||
aliyunCaptchaRegion.value = settings.aliyun_captcha_region || 'cn'
|
||||
} catch (error) {
|
||||
console.error('Failed to load public settings:', error)
|
||||
}
|
||||
@@ -222,13 +245,13 @@ function resetCaptchaProof(): void {
|
||||
errors.turnstile = ''
|
||||
}
|
||||
|
||||
async function acquireTencentProof(): Promise<boolean> {
|
||||
if (!tencentCaptchaEnabled.value) return true
|
||||
async function acquireActionProof(): Promise<boolean> {
|
||||
if (!actionCaptchaEnabled.value) return true
|
||||
|
||||
const proof = await turnstileRef.value?.verifyTencent()
|
||||
const proof = await turnstileRef.value?.verifyAction()
|
||||
if (!proof) return false
|
||||
|
||||
turnstileToken.value = proof.ticket
|
||||
turnstileToken.value = proof.token
|
||||
tencentCaptchaRandstr.value = proof.randstr
|
||||
return true
|
||||
}
|
||||
@@ -268,7 +291,7 @@ async function handleSubmit(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
if (!(await acquireTencentProof())) {
|
||||
if (!(await acquireActionProof())) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -277,7 +300,8 @@ async function handleSubmit(): Promise<void> {
|
||||
try {
|
||||
await forgotPassword({
|
||||
email: formData.email,
|
||||
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined,
|
||||
turnstile_token:
|
||||
turnstileEnabled.value || aliyunCaptchaEnabled.value ? turnstileToken.value : undefined,
|
||||
tencent_captcha_ticket: tencentCaptchaEnabled.value ? turnstileToken.value : undefined,
|
||||
tencent_captcha_randstr: tencentCaptchaEnabled.value ? tencentCaptchaRandstr.value : undefined
|
||||
})
|
||||
|
||||
@@ -86,6 +86,10 @@
|
||||
:turnstile-site-key="turnstileSiteKey"
|
||||
:tencent-enabled="tencentCaptchaEnabled"
|
||||
:tencent-app-id="tencentCaptchaAppId"
|
||||
:aliyun-enabled="aliyunCaptchaEnabled"
|
||||
:aliyun-scene-id="aliyunCaptchaSceneId"
|
||||
:aliyun-prefix="aliyunCaptchaPrefix"
|
||||
:aliyun-region="aliyunCaptchaRegion"
|
||||
@verify="onTurnstileVerify"
|
||||
@expire="onTurnstileExpire"
|
||||
@error="onTurnstileError"
|
||||
@@ -240,8 +244,8 @@ import {
|
||||
type OAuthLoginStart
|
||||
} from '@/api/auth'
|
||||
import type {
|
||||
ActionCaptchaRequestProof,
|
||||
LoginAgreementDocument,
|
||||
TencentCaptchaRequestProof,
|
||||
TotpLoginResponse
|
||||
} from '@/types'
|
||||
import { extractI18nErrorMessage } from '@/utils/apiError'
|
||||
@@ -269,6 +273,10 @@ const turnstileEnabled = ref<boolean>(false)
|
||||
const turnstileSiteKey = ref<string>('')
|
||||
const tencentCaptchaEnabled = ref<boolean>(false)
|
||||
const tencentCaptchaAppId = ref<string>('')
|
||||
const aliyunCaptchaEnabled = ref<boolean>(false)
|
||||
const aliyunCaptchaSceneId = ref<string>('')
|
||||
const aliyunCaptchaPrefix = ref<string>('')
|
||||
const aliyunCaptchaRegion = ref<string>('cn')
|
||||
const linuxdoOAuthEnabled = ref<boolean>(false)
|
||||
const dingtalkOAuthEnabled = ref<boolean>(false)
|
||||
const wechatOAuthEnabled = ref<boolean>(false)
|
||||
@@ -291,10 +299,21 @@ const showAgreementModal = ref<boolean>(false)
|
||||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||||
const turnstileToken = ref<string>('')
|
||||
const tencentCaptchaRandstr = ref<string>('')
|
||||
const aliyunCaptchaReady = computed(
|
||||
() =>
|
||||
aliyunCaptchaEnabled.value &&
|
||||
Boolean(aliyunCaptchaSceneId.value) &&
|
||||
Boolean(aliyunCaptchaPrefix.value)
|
||||
)
|
||||
// 动作触发式验证码(腾讯/阿里云):提交、OAuth 启动、passkey 时弹窗验证
|
||||
const actionCaptchaEnabled = computed(
|
||||
() =>
|
||||
(tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value)) ||
|
||||
aliyunCaptchaReady.value
|
||||
)
|
||||
const captchaEnabled = computed(
|
||||
() =>
|
||||
(turnstileEnabled.value && Boolean(turnstileSiteKey.value)) ||
|
||||
(tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value))
|
||||
(turnstileEnabled.value && Boolean(turnstileSiteKey.value)) || actionCaptchaEnabled.value
|
||||
)
|
||||
|
||||
// 2FA state
|
||||
@@ -364,6 +383,10 @@ onMounted(async () => {
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||
tencentCaptchaEnabled.value = settings.tencent_captcha_enabled === true
|
||||
tencentCaptchaAppId.value = settings.tencent_captcha_app_id || ''
|
||||
aliyunCaptchaEnabled.value = settings.aliyun_captcha_enabled === true
|
||||
aliyunCaptchaSceneId.value = settings.aliyun_captcha_scene_id || ''
|
||||
aliyunCaptchaPrefix.value = settings.aliyun_captcha_prefix || ''
|
||||
aliyunCaptchaRegion.value = settings.aliyun_captcha_region || 'cn'
|
||||
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
|
||||
dingtalkOAuthEnabled.value = settings.dingtalk_oauth_enabled ?? false
|
||||
wechatOAuthEnabled.value = isWeChatWebOAuthEnabled(settings)
|
||||
@@ -474,13 +497,13 @@ function resetCaptchaProof(): void {
|
||||
errors.turnstile = ''
|
||||
}
|
||||
|
||||
async function acquireTencentProof(): Promise<boolean> {
|
||||
if (!tencentCaptchaEnabled.value) return true
|
||||
async function acquireActionProof(): Promise<boolean> {
|
||||
if (!actionCaptchaEnabled.value) return true
|
||||
|
||||
const proof = await turnstileRef.value?.verifyTencent()
|
||||
const proof = await turnstileRef.value?.verifyAction()
|
||||
if (!proof) return false
|
||||
|
||||
turnstileToken.value = proof.ticket
|
||||
turnstileToken.value = proof.token
|
||||
tencentCaptchaRandstr.value = proof.randstr
|
||||
return true
|
||||
}
|
||||
@@ -541,18 +564,19 @@ async function handleLogin(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
if (!(await acquireTencentProof())) {
|
||||
if (!(await acquireActionProof())) {
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
// Call auth store login
|
||||
// Call auth store login(阿里云 captchaVerifyParam 复用 turnstile_token 字段)
|
||||
const response = await authStore.login({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined,
|
||||
turnstile_token:
|
||||
turnstileEnabled.value || aliyunCaptchaEnabled.value ? turnstileToken.value : undefined,
|
||||
tencent_captcha_ticket: tencentCaptchaEnabled.value ? turnstileToken.value : undefined,
|
||||
tencent_captcha_randstr: tencentCaptchaEnabled.value
|
||||
? tencentCaptchaRandstr.value
|
||||
@@ -600,14 +624,16 @@ async function handlePasskeyLogin(): Promise<void> {
|
||||
|
||||
passkeyLoading.value = true
|
||||
try {
|
||||
let proof: TencentCaptchaRequestProof | undefined
|
||||
if (tencentCaptchaEnabled.value) {
|
||||
const result = await turnstileRef.value?.verifyTencent()
|
||||
let proof: ActionCaptchaRequestProof | undefined
|
||||
if (actionCaptchaEnabled.value) {
|
||||
const result = await turnstileRef.value?.verifyAction()
|
||||
if (!result) return
|
||||
proof = {
|
||||
tencent_captcha_ticket: result.ticket,
|
||||
tencent_captcha_randstr: result.randstr
|
||||
}
|
||||
proof = tencentCaptchaEnabled.value
|
||||
? {
|
||||
tencent_captcha_ticket: result.token,
|
||||
tencent_captcha_randstr: result.randstr
|
||||
}
|
||||
: { turnstile_token: result.token }
|
||||
}
|
||||
|
||||
await authStore.loginWithPasskey(proof)
|
||||
@@ -622,7 +648,7 @@ async function handlePasskeyLogin(): Promise<void> {
|
||||
errorMessage.value = extractI18nErrorMessage(error, t, 'auth.errors', fallback)
|
||||
appStore.showError(errorMessage.value)
|
||||
} finally {
|
||||
if (tencentCaptchaEnabled.value) {
|
||||
if (actionCaptchaEnabled.value) {
|
||||
resetCaptchaProof()
|
||||
}
|
||||
passkeyLoading.value = false
|
||||
@@ -632,20 +658,25 @@ async function handlePasskeyLogin(): Promise<void> {
|
||||
async function handleOAuthStart(request: OAuthLoginStart): Promise<void> {
|
||||
if (authActionDisabled.value) return
|
||||
|
||||
if (!tencentCaptchaEnabled.value) {
|
||||
if (!actionCaptchaEnabled.value) {
|
||||
window.location.href = buildOAuthLoginStartURL(request)
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
const proof = await turnstileRef.value?.verifyTencent()
|
||||
const proof = await turnstileRef.value?.verifyAction()
|
||||
if (!proof) return
|
||||
|
||||
const result = await startOAuthLogin(request, {
|
||||
tencent_captcha_ticket: proof.ticket,
|
||||
tencent_captcha_randstr: proof.randstr
|
||||
})
|
||||
const result = await startOAuthLogin(
|
||||
request,
|
||||
tencentCaptchaEnabled.value
|
||||
? {
|
||||
tencent_captcha_ticket: proof.token,
|
||||
tencent_captcha_randstr: proof.randstr
|
||||
}
|
||||
: { turnstile_token: proof.token }
|
||||
)
|
||||
window.location.href = result.authorize_url
|
||||
} catch (error: unknown) {
|
||||
errorMessage.value = extractI18nErrorMessage(
|
||||
|
||||
@@ -211,6 +211,10 @@
|
||||
:turnstile-site-key="turnstileSiteKey"
|
||||
:tencent-enabled="tencentCaptchaEnabled"
|
||||
:tencent-app-id="tencentCaptchaAppId"
|
||||
:aliyun-enabled="aliyunCaptchaEnabled"
|
||||
:aliyun-scene-id="aliyunCaptchaSceneId"
|
||||
:aliyun-prefix="aliyunCaptchaPrefix"
|
||||
:aliyun-region="aliyunCaptchaRegion"
|
||||
@verify="onTurnstileVerify"
|
||||
@expire="onTurnstileExpire"
|
||||
@error="onTurnstileError"
|
||||
@@ -388,6 +392,10 @@ const turnstileEnabled = ref<boolean>(false)
|
||||
const turnstileSiteKey = ref<string>('')
|
||||
const tencentCaptchaEnabled = ref<boolean>(false)
|
||||
const tencentCaptchaAppId = ref<string>('')
|
||||
const aliyunCaptchaEnabled = ref<boolean>(false)
|
||||
const aliyunCaptchaSceneId = ref<string>('')
|
||||
const aliyunCaptchaPrefix = ref<string>('')
|
||||
const aliyunCaptchaRegion = ref<string>('cn')
|
||||
const siteName = ref<string>('Sub2API')
|
||||
const linuxdoOAuthEnabled = ref<boolean>(false)
|
||||
const wechatOAuthEnabled = ref<boolean>(false)
|
||||
@@ -408,10 +416,21 @@ const showAgreementModal = ref<boolean>(false)
|
||||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||||
const turnstileToken = ref<string>('')
|
||||
const tencentCaptchaRandstr = ref<string>('')
|
||||
const aliyunCaptchaReady = computed(
|
||||
() =>
|
||||
aliyunCaptchaEnabled.value &&
|
||||
Boolean(aliyunCaptchaSceneId.value) &&
|
||||
Boolean(aliyunCaptchaPrefix.value)
|
||||
)
|
||||
// 动作触发式验证码(腾讯/阿里云):提交、OAuth 启动时弹窗验证
|
||||
const actionCaptchaEnabled = computed(
|
||||
() =>
|
||||
(tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value)) ||
|
||||
aliyunCaptchaReady.value
|
||||
)
|
||||
const captchaEnabled = computed(
|
||||
() =>
|
||||
(turnstileEnabled.value && Boolean(turnstileSiteKey.value)) ||
|
||||
(tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value))
|
||||
(turnstileEnabled.value && Boolean(turnstileSiteKey.value)) || actionCaptchaEnabled.value
|
||||
)
|
||||
|
||||
// Promo code validation
|
||||
@@ -505,6 +524,10 @@ onMounted(async () => {
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||
tencentCaptchaEnabled.value = settings.tencent_captcha_enabled === true
|
||||
tencentCaptchaAppId.value = settings.tencent_captcha_app_id || ''
|
||||
aliyunCaptchaEnabled.value = settings.aliyun_captcha_enabled === true
|
||||
aliyunCaptchaSceneId.value = settings.aliyun_captcha_scene_id || ''
|
||||
aliyunCaptchaPrefix.value = settings.aliyun_captcha_prefix || ''
|
||||
aliyunCaptchaRegion.value = settings.aliyun_captcha_region || 'cn'
|
||||
siteName.value = settings.site_name || 'Sub2API'
|
||||
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
|
||||
wechatOAuthEnabled.value = isWeChatWebOAuthEnabled(settings)
|
||||
@@ -778,13 +801,13 @@ function resetCaptchaProof(): void {
|
||||
errors.turnstile = ''
|
||||
}
|
||||
|
||||
async function acquireTencentProof(): Promise<boolean> {
|
||||
if (!tencentCaptchaEnabled.value) return true
|
||||
async function acquireActionProof(): Promise<boolean> {
|
||||
if (!actionCaptchaEnabled.value) return true
|
||||
|
||||
const proof = await turnstileRef.value?.verifyTencent()
|
||||
const proof = await turnstileRef.value?.verifyAction()
|
||||
if (!proof) return false
|
||||
|
||||
turnstileToken.value = proof.ticket
|
||||
turnstileToken.value = proof.token
|
||||
tencentCaptchaRandstr.value = proof.randstr
|
||||
return true
|
||||
}
|
||||
@@ -792,20 +815,25 @@ async function acquireTencentProof(): Promise<boolean> {
|
||||
async function handleOAuthStart(request: OAuthLoginStart): Promise<void> {
|
||||
if (registrationActionDisabled.value) return
|
||||
|
||||
if (!tencentCaptchaEnabled.value) {
|
||||
if (!actionCaptchaEnabled.value) {
|
||||
window.location.href = buildOAuthLoginStartURL(request)
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
const proof = await turnstileRef.value?.verifyTencent()
|
||||
const proof = await turnstileRef.value?.verifyAction()
|
||||
if (!proof) return
|
||||
|
||||
const result = await startOAuthLogin(request, {
|
||||
tencent_captcha_ticket: proof.ticket,
|
||||
tencent_captcha_randstr: proof.randstr
|
||||
})
|
||||
const result = await startOAuthLogin(
|
||||
request,
|
||||
tencentCaptchaEnabled.value
|
||||
? {
|
||||
tencent_captcha_ticket: proof.token,
|
||||
tencent_captcha_randstr: proof.randstr
|
||||
}
|
||||
: { turnstile_token: proof.token }
|
||||
)
|
||||
window.location.href = result.authorize_url
|
||||
} catch (error: unknown) {
|
||||
errorMessage.value = extractI18nErrorMessage(
|
||||
@@ -950,7 +978,7 @@ async function handleRegister(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
if (!(await acquireTencentProof())) {
|
||||
if (!(await acquireActionProof())) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -970,7 +998,8 @@ async function handleRegister(): Promise<void> {
|
||||
JSON.stringify({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined,
|
||||
turnstile_token:
|
||||
turnstileEnabled.value || aliyunCaptchaEnabled.value ? turnstileToken.value : undefined,
|
||||
tencent_captcha_ticket: tencentCaptchaEnabled.value ? turnstileToken.value : undefined,
|
||||
tencent_captcha_randstr: tencentCaptchaEnabled.value ? tencentCaptchaRandstr.value : undefined,
|
||||
promo_code: formData.promo_code || undefined,
|
||||
@@ -988,7 +1017,8 @@ async function handleRegister(): Promise<void> {
|
||||
await authStore.register({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined,
|
||||
turnstile_token:
|
||||
turnstileEnabled.value || aliyunCaptchaEnabled.value ? turnstileToken.value : undefined,
|
||||
tencent_captcha_ticket: tencentCaptchaEnabled.value ? turnstileToken.value : undefined,
|
||||
tencent_captcha_randstr: tencentCaptchaEnabled.value ? tencentCaptchaRandstr.value : undefined,
|
||||
promo_code: formData.promo_code || undefined,
|
||||
|
||||
@@ -18,7 +18,7 @@ const {
|
||||
apiClientPostMock,
|
||||
authStoreState,
|
||||
createTurnstileResetMock,
|
||||
verifyTencentMock,
|
||||
verifyActionMock,
|
||||
} = vi.hoisted(() => ({
|
||||
pushMock: vi.fn(),
|
||||
showSuccessMock: vi.fn(),
|
||||
@@ -33,7 +33,7 @@ const {
|
||||
persistOAuthTokenContextMock: vi.fn(),
|
||||
apiClientPostMock: vi.fn(),
|
||||
createTurnstileResetMock: vi.fn(),
|
||||
verifyTencentMock: vi.fn(),
|
||||
verifyActionMock: vi.fn(),
|
||||
authStoreState: {
|
||||
pendingAuthSession: null as null | {
|
||||
token: string
|
||||
@@ -116,7 +116,7 @@ describe('EmailVerifyView', () => {
|
||||
persistOAuthTokenContextMock.mockReset()
|
||||
apiClientPostMock.mockReset()
|
||||
createTurnstileResetMock.mockReset()
|
||||
verifyTencentMock.mockReset()
|
||||
verifyActionMock.mockReset()
|
||||
authStoreState.pendingAuthSession = null
|
||||
sessionStorage.clear()
|
||||
localStorage.clear()
|
||||
@@ -142,9 +142,9 @@ describe('EmailVerifyView', () => {
|
||||
registration_email_suffix_whitelist: [],
|
||||
})
|
||||
sendVerifyCodeMock.mockResolvedValue({ countdown: 0 })
|
||||
verifyTencentMock
|
||||
.mockResolvedValueOnce({ ticket: 'ticket-1', randstr: '@rand-1' })
|
||||
.mockResolvedValueOnce({ ticket: 'ticket-2', randstr: '@rand-2' })
|
||||
verifyActionMock
|
||||
.mockResolvedValueOnce({ token: 'ticket-1', randstr: '@rand-1' })
|
||||
.mockResolvedValueOnce({ token: 'ticket-2', randstr: '@rand-2' })
|
||||
sessionStorage.setItem(
|
||||
'register_data',
|
||||
JSON.stringify({
|
||||
@@ -157,7 +157,7 @@ describe('EmailVerifyView', () => {
|
||||
|
||||
const CaptchaChallengeStub = defineComponent({
|
||||
setup(_, { expose }) {
|
||||
expose({ verifyTencent: verifyTencentMock, reset: createTurnstileResetMock })
|
||||
expose({ verifyAction: verifyActionMock, reset: createTurnstileResetMock })
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
@@ -182,7 +182,7 @@ describe('EmailVerifyView', () => {
|
||||
await resendButton().trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(verifyTencentMock).toHaveBeenCalledTimes(2)
|
||||
expect(verifyActionMock).toHaveBeenCalledTimes(2)
|
||||
expect(sendVerifyCodeMock).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||
tencent_captcha_ticket: 'ticket-1',
|
||||
tencent_captcha_randstr: '@rand-1',
|
||||
|
||||
@@ -7,7 +7,7 @@ const loginMock = vi.fn()
|
||||
const loginWithPasskeyMock = vi.fn()
|
||||
const getPublicSettingsMock = vi.fn()
|
||||
const startOAuthLoginMock = vi.fn()
|
||||
const verifyTencentMock = vi.fn()
|
||||
const verifyActionMock = vi.fn()
|
||||
const captchaResetMock = vi.fn()
|
||||
const locationState = { href: 'http://localhost/login' }
|
||||
|
||||
@@ -54,7 +54,7 @@ vi.mock('@/api/auth', async () => {
|
||||
const CaptchaChallengeStub = defineComponent({
|
||||
setup(_, { expose }) {
|
||||
expose({
|
||||
verifyTencent: verifyTencentMock,
|
||||
verifyAction: verifyActionMock,
|
||||
reset: captchaResetMock
|
||||
})
|
||||
return () => h('div')
|
||||
@@ -101,7 +101,7 @@ describe('Tencent captcha action gate', () => {
|
||||
loginWithPasskeyMock.mockReset()
|
||||
getPublicSettingsMock.mockReset()
|
||||
startOAuthLoginMock.mockReset()
|
||||
verifyTencentMock.mockReset()
|
||||
verifyActionMock.mockReset()
|
||||
captchaResetMock.mockReset()
|
||||
getPublicSettingsMock.mockResolvedValue({
|
||||
turnstile_enabled: false,
|
||||
@@ -117,7 +117,7 @@ describe('Tencent captcha action gate', () => {
|
||||
loginMock.mockResolvedValue({})
|
||||
loginWithPasskeyMock.mockResolvedValue({})
|
||||
startOAuthLoginMock.mockResolvedValue({ authorize_url: 'https://github.example/authorize' })
|
||||
verifyTencentMock.mockResolvedValue({ ticket: 'ticket-1', randstr: '@rand-1' })
|
||||
verifyActionMock.mockResolvedValue({ token: 'ticket-1', randstr: '@rand-1' })
|
||||
Object.defineProperty(window, 'PublicKeyCredential', {
|
||||
configurable: true,
|
||||
value: class PublicKeyCredential {}
|
||||
@@ -138,7 +138,7 @@ describe('Tencent captcha action gate', () => {
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(verifyTencentMock).toHaveBeenCalledOnce()
|
||||
expect(verifyActionMock).toHaveBeenCalledOnce()
|
||||
expect(loginMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
tencent_captcha_ticket: 'ticket-1',
|
||||
tencent_captcha_randstr: '@rand-1'
|
||||
@@ -146,7 +146,7 @@ describe('Tencent captcha action gate', () => {
|
||||
})
|
||||
|
||||
it('does not call login when Tencent captcha is closed', async () => {
|
||||
verifyTencentMock.mockResolvedValue(null)
|
||||
verifyActionMock.mockResolvedValue(null)
|
||||
const wrapper = mountLogin()
|
||||
await flushPromises()
|
||||
await wrapper.get('#email').setValue('user@example.com')
|
||||
@@ -155,7 +155,7 @@ describe('Tencent captcha action gate', () => {
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(verifyTencentMock).toHaveBeenCalledOnce()
|
||||
expect(verifyActionMock).toHaveBeenCalledOnce()
|
||||
expect(loginMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -166,7 +166,7 @@ describe('Tencent captcha action gate', () => {
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(verifyTencentMock).not.toHaveBeenCalled()
|
||||
expect(verifyActionMock).not.toHaveBeenCalled()
|
||||
expect(loginMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -177,7 +177,7 @@ describe('Tencent captcha action gate', () => {
|
||||
await wrapper.get('[data-testid="oauth-start"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(verifyTencentMock).toHaveBeenCalledOnce()
|
||||
expect(verifyActionMock).toHaveBeenCalledOnce()
|
||||
expect(startOAuthLoginMock).toHaveBeenCalledWith(
|
||||
{ provider: 'github', params: { redirect: '/dashboard' } },
|
||||
{
|
||||
@@ -190,7 +190,7 @@ describe('Tencent captcha action gate', () => {
|
||||
})
|
||||
|
||||
it('does not start OAuth when Tencent captcha is closed', async () => {
|
||||
verifyTencentMock.mockResolvedValue(null)
|
||||
verifyActionMock.mockResolvedValue(null)
|
||||
const wrapper = mountLogin()
|
||||
await flushPromises()
|
||||
|
||||
@@ -208,7 +208,7 @@ describe('Tencent captcha action gate', () => {
|
||||
await wrapper.get('button.btn-secondary.w-full').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(verifyTencentMock).toHaveBeenCalledOnce()
|
||||
expect(verifyActionMock).toHaveBeenCalledOnce()
|
||||
expect(loginWithPasskeyMock).toHaveBeenCalledWith({
|
||||
tencent_captcha_ticket: 'ticket-1',
|
||||
tencent_captcha_randstr: '@rand-1'
|
||||
@@ -217,7 +217,7 @@ describe('Tencent captcha action gate', () => {
|
||||
})
|
||||
|
||||
it('does not invoke Passkey when Tencent captcha is closed', async () => {
|
||||
verifyTencentMock.mockResolvedValue(null)
|
||||
verifyActionMock.mockResolvedValue(null)
|
||||
const wrapper = mountLogin()
|
||||
await flushPromises()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user