人机验证增加阿里云验证码 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:
feeeei committed 2026-08-04 20:57:15 +08:00
1 parent d431c57f2e
commit 26e0a89323
57 files changed
+2445 -302

No files matched your search

+310 -128
View File
@@ -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,
+50 -18
View File
@@ -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,
+32 -8
View File
@@ -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
})
+55 -24
View File
@@ -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(
+45 -15
View File
@@ -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()