mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-31 08:25:35 +00:00
新增腾讯天御验证码认证门禁
This commit is contained in:
@@ -1990,7 +1990,11 @@
|
||||
{{ t("admin.settings.turnstile.enableTurnstileHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="form.turnstile_enabled" />
|
||||
<Toggle
|
||||
v-model="form.turnstile_enabled"
|
||||
data-testid="turnstile-enabled-toggle"
|
||||
@update:model-value="onTurnstileToggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Turnstile Keys - Only show when enabled -->
|
||||
@@ -2050,6 +2054,151 @@
|
||||
</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") }}
|
||||
</p>
|
||||
</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>
|
||||
</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-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>
|
||||
|
||||
<!-- LinuxDo Connect OAuth 登录 -->
|
||||
<div class="card">
|
||||
<div
|
||||
@@ -8779,6 +8928,9 @@ type SettingsForm = Omit<
|
||||
> & {
|
||||
smtp_password: string;
|
||||
turnstile_secret_key: string;
|
||||
tencent_captcha_app_secret_key: string;
|
||||
tencent_captcha_cloud_secret_id: string;
|
||||
tencent_captcha_cloud_secret_key: string;
|
||||
linuxdo_connect_client_secret: string;
|
||||
dingtalk_connect_client_secret: string;
|
||||
wechat_connect_app_secret: string;
|
||||
@@ -8908,6 +9060,14 @@ const form = reactive<SettingsForm>({
|
||||
turnstile_site_key: "",
|
||||
turnstile_secret_key: "",
|
||||
turnstile_secret_key_configured: false,
|
||||
tencent_captcha_enabled: false,
|
||||
tencent_captcha_app_id: "",
|
||||
tencent_captcha_app_secret_key: "",
|
||||
tencent_captcha_app_secret_key_configured: false,
|
||||
tencent_captcha_cloud_secret_id: "",
|
||||
tencent_captcha_cloud_secret_id_configured: false,
|
||||
tencent_captcha_cloud_secret_key: "",
|
||||
tencent_captcha_cloud_secret_key_configured: false,
|
||||
api_key_acl_trust_forwarded_ip: true,
|
||||
forwarded_client_ip_headers: [],
|
||||
// LinuxDo Connect OAuth 登录
|
||||
@@ -9067,6 +9227,14 @@ const form = reactive<SettingsForm>({
|
||||
allow_user_view_error_requests: false,
|
||||
});
|
||||
|
||||
function onTurnstileToggle(enabled: boolean): void {
|
||||
if (enabled) form.tencent_captcha_enabled = false;
|
||||
}
|
||||
|
||||
function onTencentCaptchaToggle(enabled: boolean): void {
|
||||
if (enabled) form.turnstile_enabled = false;
|
||||
}
|
||||
|
||||
type OpenAIAdvancedSchedulerOverrideKey =
|
||||
| "openai_advanced_scheduler_lb_top_k"
|
||||
| "openai_advanced_scheduler_weight_priority"
|
||||
@@ -10024,6 +10192,9 @@ async function loadSettings() {
|
||||
form.smtp_password = "";
|
||||
smtpPasswordManuallyEdited.value = false;
|
||||
form.turnstile_secret_key = "";
|
||||
form.tencent_captcha_app_secret_key = "";
|
||||
form.tencent_captcha_cloud_secret_id = "";
|
||||
form.tencent_captcha_cloud_secret_key = "";
|
||||
form.linuxdo_connect_client_secret = "";
|
||||
form.dingtalk_connect_client_secret = "";
|
||||
form.github_oauth_client_secret = "";
|
||||
@@ -10393,6 +10564,14 @@ async function saveSettings() {
|
||||
turnstile_enabled: form.turnstile_enabled,
|
||||
turnstile_site_key: form.turnstile_site_key,
|
||||
turnstile_secret_key: form.turnstile_secret_key || undefined,
|
||||
tencent_captcha_enabled: form.tencent_captcha_enabled,
|
||||
tencent_captcha_app_id: form.tencent_captcha_app_id,
|
||||
tencent_captcha_app_secret_key:
|
||||
form.tencent_captcha_app_secret_key || undefined,
|
||||
tencent_captcha_cloud_secret_id:
|
||||
form.tencent_captcha_cloud_secret_id || undefined,
|
||||
tencent_captcha_cloud_secret_key:
|
||||
form.tencent_captcha_cloud_secret_key || undefined,
|
||||
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,
|
||||
|
||||
@@ -396,6 +396,11 @@ const baseSettingsResponse = {
|
||||
turnstile_enabled: false,
|
||||
turnstile_site_key: "",
|
||||
turnstile_secret_key_configured: false,
|
||||
tencent_captcha_enabled: false,
|
||||
tencent_captcha_app_id: "",
|
||||
tencent_captcha_app_secret_key_configured: false,
|
||||
tencent_captcha_cloud_secret_id_configured: false,
|
||||
tencent_captcha_cloud_secret_key_configured: false,
|
||||
api_key_acl_trust_forwarded_ip: true,
|
||||
forwarded_client_ip_headers: [],
|
||||
linuxdo_connect_enabled: false,
|
||||
@@ -767,6 +772,50 @@ describe("admin SettingsView payment visible method controls", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("腾讯天御验证码与 Turnstile 开关互斥并保存四项配置", 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);
|
||||
|
||||
await tencentToggle.setValue(true);
|
||||
expect((turnstileToggle.element as HTMLInputElement).checked).toBe(false);
|
||||
expect((tencentToggle.element as HTMLInputElement).checked).toBe(true);
|
||||
|
||||
const card = wrapper
|
||||
.findAll(".card")
|
||||
.find((node) => node.text().includes("admin.settings.tencentCaptcha.title"));
|
||||
expect(card).toBeDefined();
|
||||
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(
|
||||
card!.get('a[href="https://cloud.tencent.com/document/product/1110/36841"]').exists(),
|
||||
).toBe(true);
|
||||
const inputs = card!.findAll("input").filter((input) => input.attributes("type") !== "checkbox");
|
||||
await inputs[0]!.setValue("123456789");
|
||||
await inputs[1]!.setValue("app-secret-value");
|
||||
await inputs[2]!.setValue("cloud-secret-id-value");
|
||||
await inputs[3]!.setValue("cloud-secret-key-value");
|
||||
|
||||
await wrapper.find("form").trigger("submit.prevent");
|
||||
await flushPromises();
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
turnstile_enabled: false,
|
||||
tencent_captcha_enabled: true,
|
||||
tencent_captcha_app_id: "123456789",
|
||||
tencent_captcha_app_secret_key: "app-secret-value",
|
||||
tencent_captcha_cloud_secret_id: "cloud-secret-id-value",
|
||||
tencent_captcha_cloud_secret_key: "cloud-secret-key-value",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("disables passkey sign-in when the RP configuration is unavailable", async () => {
|
||||
getSettings.mockResolvedValueOnce({
|
||||
...baseSettingsResponse,
|
||||
|
||||
@@ -683,6 +683,13 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
|
||||
email: payload.email,
|
||||
password: payload.password,
|
||||
verify_code: payload.verifyCode || undefined,
|
||||
...(payload.turnstileToken ? { turnstile_token: payload.turnstileToken } : {}),
|
||||
...(payload.tencentCaptchaTicket
|
||||
? {
|
||||
tencent_captcha_ticket: payload.tencentCaptchaTicket,
|
||||
tencent_captcha_randstr: payload.tencentCaptchaRandstr
|
||||
}
|
||||
: {}),
|
||||
invitation_code: payload.invitationCode || undefined,
|
||||
...oauthAffiliatePayload(loadOAuthAffiliateCode()),
|
||||
...serializeAdoptionDecision(currentAdoptionDecision())
|
||||
|
||||
@@ -82,6 +82,13 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
|
||||
email: payload.email,
|
||||
password: payload.password,
|
||||
verify_code: payload.verifyCode || undefined,
|
||||
...(payload.turnstileToken ? { turnstile_token: payload.turnstileToken } : {}),
|
||||
...(payload.tencentCaptchaTicket
|
||||
? {
|
||||
tencent_captcha_ticket: payload.tencentCaptchaTicket,
|
||||
tencent_captcha_randstr: payload.tencentCaptchaRandstr
|
||||
}
|
||||
: {}),
|
||||
invitation_code: payload.invitationCode || undefined
|
||||
}
|
||||
)
|
||||
|
||||
@@ -67,18 +67,40 @@
|
||||
</div>
|
||||
|
||||
<!-- Turnstile Widget for Resend -->
|
||||
<div v-if="turnstileEnabled && turnstileSiteKey && showResendTurnstile">
|
||||
<div v-if="tencentCaptchaEnabled || (turnstileEnabled && showResendTurnstile)">
|
||||
<TurnstileWidget
|
||||
ref="turnstileRef"
|
||||
:site-key="turnstileSiteKey"
|
||||
:turnstile-enabled="turnstileEnabled"
|
||||
:turnstile-site-key="turnstileSiteKey"
|
||||
:tencent-enabled="tencentCaptchaEnabled"
|
||||
:tencent-app-id="tencentCaptchaAppId"
|
||||
@verify="onTurnstileVerify"
|
||||
@expire="onTurnstileExpire"
|
||||
@error="onTurnstileError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="pendingOAuthCreateCaptchaEnabled" class="space-y-2">
|
||||
<TurnstileWidget
|
||||
ref="createAccountTurnstileRef"
|
||||
:site-key="turnstileSiteKey"
|
||||
:turnstile-enabled="turnstileEnabled"
|
||||
:turnstile-site-key="turnstileSiteKey"
|
||||
:tencent-enabled="tencentCaptchaEnabled"
|
||||
:tencent-app-id="tencentCaptchaAppId"
|
||||
@verify="onCreateAccountTurnstileVerify"
|
||||
@expire="onCreateAccountTurnstileExpire"
|
||||
@error="onCreateAccountTurnstileError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button type="submit" :disabled="isLoading || !verifyCode" class="btn btn-primary w-full">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isLoading || !verifyCode || (pendingOAuthCreateTurnstileRequired && !createAccountTurnstileToken)"
|
||||
class="btn btn-primary w-full"
|
||||
>
|
||||
<svg
|
||||
v-if="isLoading"
|
||||
class="-ml-1 mr-2 h-4 w-4 animate-spin text-white"
|
||||
@@ -123,7 +145,7 @@
|
||||
class="text-sm text-primary-600 transition-colors hover:text-primary-500 disabled:cursor-not-allowed disabled:opacity-50 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>
|
||||
<span v-if="isSendingCode">{{ t('auth.sendingCode') }}</span>
|
||||
<span v-else-if="turnstileEnabled && !showResendTurnstile">
|
||||
<span v-else-if="captchaEnabled && !showResendTurnstile">
|
||||
{{ t('auth.clickToResend') }}
|
||||
</span>
|
||||
<span v-else>{{ t('auth.resendCode') }}</span>
|
||||
@@ -151,7 +173,7 @@ import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||
import TurnstileWidget from '@/components/CaptchaChallenge.vue'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
import {
|
||||
persistOAuthTokenContext,
|
||||
@@ -213,6 +235,7 @@ type PendingOAuthCreateAccountResponse = {
|
||||
const email = ref<string>('')
|
||||
const password = ref<string>('')
|
||||
const initialTurnstileToken = ref<string>('')
|
||||
const initialTencentCaptchaRandstr = ref<string>('')
|
||||
const promoCode = ref<string>('')
|
||||
const invitationCode = ref<string>('')
|
||||
const affCode = ref<string>('')
|
||||
@@ -229,13 +252,24 @@ const hasRegisterData = ref<boolean>(false)
|
||||
// Public settings
|
||||
const turnstileEnabled = ref<boolean>(false)
|
||||
const turnstileSiteKey = ref<string>('')
|
||||
const tencentCaptchaEnabled = ref<boolean>(false)
|
||||
const tencentCaptchaAppId = ref<string>('')
|
||||
const siteName = ref<string>('Sub2API')
|
||||
const registrationEmailSuffixWhitelist = ref<string[]>([])
|
||||
|
||||
// Turnstile for resend
|
||||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||||
const createAccountTurnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||||
const resendTurnstileToken = ref<string>('')
|
||||
const resendTencentCaptchaRandstr = ref<string>('')
|
||||
const createAccountTurnstileToken = ref<string>('')
|
||||
const createAccountTencentCaptchaRandstr = ref<string>('')
|
||||
const showResendTurnstile = ref<boolean>(false)
|
||||
const captchaEnabled = computed(
|
||||
() =>
|
||||
(turnstileEnabled.value && Boolean(turnstileSiteKey.value)) ||
|
||||
(tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value))
|
||||
)
|
||||
|
||||
const errors = ref({
|
||||
code: '',
|
||||
@@ -245,6 +279,12 @@ const errors = ref({
|
||||
const validationToastMessage = computed(
|
||||
() => errors.value.code || errors.value.turnstile || ''
|
||||
)
|
||||
const pendingOAuthCreateTurnstileRequired = computed(
|
||||
() => isPendingOAuthFlow() && turnstileEnabled.value
|
||||
)
|
||||
const pendingOAuthCreateCaptchaEnabled = computed(
|
||||
() => isPendingOAuthFlow() && captchaEnabled.value
|
||||
)
|
||||
|
||||
watch(validationToastMessage, (value, previousValue) => {
|
||||
if (value && value !== previousValue) {
|
||||
@@ -264,7 +304,9 @@ onMounted(async () => {
|
||||
const registerData = JSON.parse(registerDataStr)
|
||||
email.value = registerData.email || ''
|
||||
password.value = registerData.password || ''
|
||||
initialTurnstileToken.value = registerData.turnstile_token || ''
|
||||
initialTurnstileToken.value =
|
||||
registerData.tencent_captcha_ticket || registerData.turnstile_token || ''
|
||||
initialTencentCaptchaRandstr.value = registerData.tencent_captcha_randstr || ''
|
||||
promoCode.value = registerData.promo_code || ''
|
||||
invitationCode.value = registerData.invitation_code || ''
|
||||
affCode.value = registerData.aff_code || loadAffiliateReferralCode()
|
||||
@@ -294,6 +336,8 @@ onMounted(async () => {
|
||||
const settings = await getPublicSettings()
|
||||
turnstileEnabled.value = settings.turnstile_enabled
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||
tencentCaptchaEnabled.value = settings.tencent_captcha_enabled === true
|
||||
tencentCaptchaAppId.value = settings.tencent_captcha_app_id || ''
|
||||
siteName.value = settings.site_name || 'Sub2API'
|
||||
registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist(
|
||||
settings.registration_email_suffix_whitelist || []
|
||||
@@ -338,21 +382,70 @@ function startCountdown(seconds: number): void {
|
||||
|
||||
// ==================== Turnstile Handlers ====================
|
||||
|
||||
function onTurnstileVerify(token: string): void {
|
||||
function onTurnstileVerify(token: string, randstr = ''): void {
|
||||
resendTurnstileToken.value = token
|
||||
resendTencentCaptchaRandstr.value = randstr
|
||||
errors.value.turnstile = ''
|
||||
}
|
||||
|
||||
function onTurnstileExpire(): void {
|
||||
resendTurnstileToken.value = ''
|
||||
resendTencentCaptchaRandstr.value = ''
|
||||
errors.value.turnstile = t('auth.turnstileExpired')
|
||||
}
|
||||
|
||||
function onTurnstileError(): void {
|
||||
resendTurnstileToken.value = ''
|
||||
resendTencentCaptchaRandstr.value = ''
|
||||
errors.value.turnstile = t('auth.turnstileFailed')
|
||||
}
|
||||
|
||||
function onCreateAccountTurnstileVerify(token: string, randstr = ''): void {
|
||||
createAccountTurnstileToken.value = token
|
||||
createAccountTencentCaptchaRandstr.value = randstr
|
||||
errors.value.turnstile = ''
|
||||
}
|
||||
|
||||
function onCreateAccountTurnstileExpire(): void {
|
||||
createAccountTurnstileToken.value = ''
|
||||
createAccountTencentCaptchaRandstr.value = ''
|
||||
errors.value.turnstile = t('auth.turnstileExpired')
|
||||
}
|
||||
|
||||
function onCreateAccountTurnstileError(): void {
|
||||
createAccountTurnstileToken.value = ''
|
||||
createAccountTencentCaptchaRandstr.value = ''
|
||||
errors.value.turnstile = t('auth.turnstileFailed')
|
||||
}
|
||||
|
||||
function resetCreateAccountTurnstile(): void {
|
||||
createAccountTurnstileToken.value = ''
|
||||
createAccountTencentCaptchaRandstr.value = ''
|
||||
createAccountTurnstileRef.value?.reset()
|
||||
}
|
||||
|
||||
async function acquireResendTencentProof(): Promise<boolean> {
|
||||
if (!tencentCaptchaEnabled.value) return true
|
||||
|
||||
const proof = await turnstileRef.value?.verifyTencent()
|
||||
if (!proof) return false
|
||||
|
||||
resendTurnstileToken.value = proof.ticket
|
||||
resendTencentCaptchaRandstr.value = proof.randstr
|
||||
return true
|
||||
}
|
||||
|
||||
async function acquireCreateAccountTencentProof(): Promise<boolean> {
|
||||
if (!isPendingOAuthFlow() || !tencentCaptchaEnabled.value) return true
|
||||
|
||||
const proof = await createAccountTurnstileRef.value?.verifyTencent()
|
||||
if (!proof) return false
|
||||
|
||||
createAccountTurnstileToken.value = proof.ticket
|
||||
createAccountTencentCaptchaRandstr.value = proof.randstr
|
||||
return true
|
||||
}
|
||||
|
||||
function isPendingOAuthFlow(): boolean {
|
||||
return Boolean(pendingProvider.value.trim())
|
||||
}
|
||||
@@ -398,6 +491,8 @@ function persistPendingOAuthSession(provider: string, redirect?: string): void {
|
||||
async function sendCode(): Promise<void> {
|
||||
isSendingCode.value = true
|
||||
errorMessage.value = ''
|
||||
let requestSucceeded = false
|
||||
let captchaProofUsed = false
|
||||
|
||||
try {
|
||||
if (!shouldBypassRegistrationEmailPolicy() && !isRegistrationEmailSuffixAllowed(email.value, registrationEmailSuffixWhitelist.value)) {
|
||||
@@ -410,11 +505,23 @@ async function sendCode(): Promise<void> {
|
||||
email: email.value,
|
||||
[pendingAuthTokenField.value]: pendingAuthToken.value || undefined,
|
||||
// 优先使用重发时新获取的 token(因为初始 token 可能已被使用)
|
||||
turnstile_token: resendTurnstileToken.value || initialTurnstileToken.value || undefined
|
||||
turnstile_token: turnstileEnabled.value
|
||||
? resendTurnstileToken.value || initialTurnstileToken.value || undefined
|
||||
: undefined,
|
||||
tencent_captcha_ticket: tencentCaptchaEnabled.value
|
||||
? resendTurnstileToken.value || initialTurnstileToken.value || undefined
|
||||
: undefined,
|
||||
tencent_captcha_randstr: tencentCaptchaEnabled.value
|
||||
? resendTencentCaptchaRandstr.value || initialTencentCaptchaRandstr.value || undefined
|
||||
: undefined
|
||||
} as Parameters<typeof sendVerifyCode>[0]
|
||||
captchaProofUsed = Boolean(
|
||||
requestPayload.turnstile_token || requestPayload.tencent_captcha_ticket
|
||||
)
|
||||
const response = isPendingOAuthFlow()
|
||||
? await sendPendingOAuthVerifyCode(requestPayload)
|
||||
: await sendVerifyCode(requestPayload)
|
||||
requestSucceeded = true
|
||||
|
||||
const pendingSendCodeSession = isPendingOAuthFlow()
|
||||
? getPendingOAuthSendCodeSessionResponse(response as PendingOAuthSendVerifyCodeResponse)
|
||||
@@ -434,10 +541,7 @@ async function sendCode(): Promise<void> {
|
||||
codeSent.value = true
|
||||
startCountdown(response.countdown)
|
||||
|
||||
// Reset turnstile state(token 已使用,清除以避免重复使用)
|
||||
initialTurnstileToken.value = ''
|
||||
showResendTurnstile.value = false
|
||||
resendTurnstileToken.value = ''
|
||||
} catch (error: unknown) {
|
||||
errorMessage.value = buildAuthErrorMessage(error, {
|
||||
fallback: t('auth.sendCodeFailed')
|
||||
@@ -445,25 +549,54 @@ async function sendCode(): Promise<void> {
|
||||
|
||||
appStore.showError(errorMessage.value)
|
||||
} finally {
|
||||
if (captchaProofUsed) {
|
||||
clearStoredCaptchaProof()
|
||||
initialTurnstileToken.value = ''
|
||||
initialTencentCaptchaRandstr.value = ''
|
||||
resendTurnstileToken.value = ''
|
||||
resendTencentCaptchaRandstr.value = ''
|
||||
turnstileRef.value?.reset()
|
||||
if (!requestSucceeded && turnstileEnabled.value) {
|
||||
showResendTurnstile.value = true
|
||||
}
|
||||
}
|
||||
isSendingCode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearStoredCaptchaProof(): void {
|
||||
const registerDataStr = sessionStorage.getItem('register_data')
|
||||
if (!registerDataStr) return
|
||||
|
||||
try {
|
||||
const registerData = JSON.parse(registerDataStr) as Record<string, unknown>
|
||||
delete registerData.turnstile_token
|
||||
delete registerData.tencent_captcha_ticket
|
||||
delete registerData.tencent_captcha_randstr
|
||||
sessionStorage.setItem('register_data', JSON.stringify(registerData))
|
||||
} catch {
|
||||
// Invalid registration state is handled by the existing onMounted parser.
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Handlers ====================
|
||||
|
||||
async function handleResendCode(): Promise<void> {
|
||||
// If turnstile is enabled and we haven't shown it yet, show it
|
||||
// Turnstile stays staged; Tencent is acquired from this action.
|
||||
if (turnstileEnabled.value && !showResendTurnstile.value) {
|
||||
showResendTurnstile.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// If turnstile is enabled but no token yet, wait
|
||||
if (turnstileEnabled.value && !resendTurnstileToken.value) {
|
||||
errors.value.turnstile = t('auth.completeVerification')
|
||||
return
|
||||
}
|
||||
|
||||
if (!(await acquireResendTencentProof())) {
|
||||
return
|
||||
}
|
||||
|
||||
await sendCode()
|
||||
}
|
||||
|
||||
@@ -490,20 +623,33 @@ async function handleVerify(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
if (!shouldBypassRegistrationEmailPolicy() && !isRegistrationEmailSuffixAllowed(email.value, registrationEmailSuffixWhitelist.value)) {
|
||||
errorMessage.value = buildEmailSuffixNotAllowedMessage()
|
||||
appStore.showError(errorMessage.value)
|
||||
return
|
||||
}
|
||||
|
||||
if (!(await acquireCreateAccountTencentProof())) {
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
if (!shouldBypassRegistrationEmailPolicy() && !isRegistrationEmailSuffixAllowed(email.value, registrationEmailSuffixWhitelist.value)) {
|
||||
errorMessage.value = buildEmailSuffixNotAllowedMessage()
|
||||
appStore.showError(errorMessage.value)
|
||||
return
|
||||
}
|
||||
|
||||
if (isPendingOAuthFlow()) {
|
||||
const payload: Record<string, unknown> = {
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
verify_code: verifyCode.value.trim(),
|
||||
...(turnstileEnabled.value && createAccountTurnstileToken.value
|
||||
? { turnstile_token: createAccountTurnstileToken.value }
|
||||
: {}),
|
||||
...(tencentCaptchaEnabled.value && createAccountTurnstileToken.value
|
||||
? {
|
||||
tencent_captcha_ticket: createAccountTurnstileToken.value,
|
||||
tencent_captcha_randstr: createAccountTencentCaptchaRandstr.value
|
||||
}
|
||||
: {}),
|
||||
...oauthAffiliatePayload(affCode.value || loadAffiliateReferralCode()),
|
||||
}
|
||||
if (invitationCode.value) {
|
||||
@@ -539,7 +685,9 @@ async function handleVerify(): Promise<void> {
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
verify_code: verifyCode.value.trim(),
|
||||
turnstile_token: initialTurnstileToken.value || undefined,
|
||||
turnstile_token: turnstileEnabled.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,
|
||||
invitation_code: invitationCode.value || undefined,
|
||||
...(affCode.value ? { aff_code: affCode.value } : {})
|
||||
@@ -562,6 +710,11 @@ async function handleVerify(): Promise<void> {
|
||||
|
||||
appStore.showError(errorMessage.value)
|
||||
} finally {
|
||||
initialTurnstileToken.value = ''
|
||||
initialTencentCaptchaRandstr.value = ''
|
||||
if (pendingOAuthCreateCaptchaEnabled.value) {
|
||||
resetCreateAccountTurnstile()
|
||||
}
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,10 +67,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Turnstile Widget -->
|
||||
<div v-if="turnstileEnabled && turnstileSiteKey">
|
||||
<div v-if="captchaEnabled">
|
||||
<TurnstileWidget
|
||||
ref="turnstileRef"
|
||||
:site-key="turnstileSiteKey"
|
||||
:turnstile-enabled="turnstileEnabled"
|
||||
:turnstile-site-key="turnstileSiteKey"
|
||||
:tencent-enabled="tencentCaptchaEnabled"
|
||||
:tencent-app-id="tencentCaptchaAppId"
|
||||
@verify="onTurnstileVerify"
|
||||
@expire="onTurnstileExpire"
|
||||
@error="onTurnstileError"
|
||||
@@ -129,7 +132,7 @@ import { computed, ref, reactive, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||
import TurnstileWidget from '@/components/CaptchaChallenge.vue'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { getPublicSettings, forgotPassword } from '@/api/auth'
|
||||
|
||||
@@ -148,10 +151,18 @@ const errorMessage = ref<string>('')
|
||||
// Public settings
|
||||
const turnstileEnabled = ref<boolean>(false)
|
||||
const turnstileSiteKey = ref<string>('')
|
||||
const tencentCaptchaEnabled = ref<boolean>(false)
|
||||
const tencentCaptchaAppId = ref<string>('')
|
||||
|
||||
// Turnstile
|
||||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||||
const turnstileToken = ref<string>('')
|
||||
const tencentCaptchaRandstr = ref<string>('')
|
||||
const captchaEnabled = computed(
|
||||
() =>
|
||||
(turnstileEnabled.value && Boolean(turnstileSiteKey.value)) ||
|
||||
(tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value))
|
||||
)
|
||||
|
||||
const formData = reactive({
|
||||
email: ''
|
||||
@@ -177,6 +188,8 @@ onMounted(async () => {
|
||||
const settings = await getPublicSettings()
|
||||
turnstileEnabled.value = settings.turnstile_enabled
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||
tencentCaptchaEnabled.value = settings.tencent_captcha_enabled === true
|
||||
tencentCaptchaAppId.value = settings.tencent_captcha_app_id || ''
|
||||
} catch (error) {
|
||||
console.error('Failed to load public settings:', error)
|
||||
}
|
||||
@@ -184,21 +197,42 @@ onMounted(async () => {
|
||||
|
||||
// ==================== Turnstile Handlers ====================
|
||||
|
||||
function onTurnstileVerify(token: string): void {
|
||||
function onTurnstileVerify(token: string, randstr = ''): void {
|
||||
turnstileToken.value = token
|
||||
tencentCaptchaRandstr.value = randstr
|
||||
errors.turnstile = ''
|
||||
}
|
||||
|
||||
function onTurnstileExpire(): void {
|
||||
turnstileToken.value = ''
|
||||
tencentCaptchaRandstr.value = ''
|
||||
errors.turnstile = t('auth.turnstileExpired')
|
||||
}
|
||||
|
||||
function onTurnstileError(): void {
|
||||
turnstileToken.value = ''
|
||||
tencentCaptchaRandstr.value = ''
|
||||
errors.turnstile = t('auth.turnstileFailed')
|
||||
}
|
||||
|
||||
function resetCaptchaProof(): void {
|
||||
turnstileRef.value?.reset()
|
||||
turnstileToken.value = ''
|
||||
tencentCaptchaRandstr.value = ''
|
||||
errors.turnstile = ''
|
||||
}
|
||||
|
||||
async function acquireTencentProof(): Promise<boolean> {
|
||||
if (!tencentCaptchaEnabled.value) return true
|
||||
|
||||
const proof = await turnstileRef.value?.verifyTencent()
|
||||
if (!proof) return false
|
||||
|
||||
turnstileToken.value = proof.ticket
|
||||
tencentCaptchaRandstr.value = proof.randstr
|
||||
return true
|
||||
}
|
||||
|
||||
// ==================== Validation ====================
|
||||
|
||||
function validateForm(): boolean {
|
||||
@@ -234,23 +268,23 @@ async function handleSubmit(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
if (!(await acquireTencentProof())) {
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
await forgotPassword({
|
||||
email: formData.email,
|
||||
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined
|
||||
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined,
|
||||
tencent_captcha_ticket: tencentCaptchaEnabled.value ? turnstileToken.value : undefined,
|
||||
tencent_captcha_randstr: tencentCaptchaEnabled.value ? tencentCaptchaRandstr.value : undefined
|
||||
})
|
||||
|
||||
isSubmitted.value = true
|
||||
appStore.showSuccess(t('auth.resetEmailSent'))
|
||||
} catch (error: unknown) {
|
||||
// Reset Turnstile on error
|
||||
if (turnstileRef.value) {
|
||||
turnstileRef.value.reset()
|
||||
turnstileToken.value = ''
|
||||
}
|
||||
|
||||
const err = error as { message?: string; response?: { data?: { detail?: string } } }
|
||||
|
||||
if (err.response?.data?.detail) {
|
||||
@@ -263,6 +297,9 @@ async function handleSubmit(): Promise<void> {
|
||||
|
||||
appStore.showError(errorMessage.value)
|
||||
} finally {
|
||||
if (captchaEnabled.value) {
|
||||
resetCaptchaProof()
|
||||
}
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -681,6 +681,13 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
|
||||
email: payload.email,
|
||||
password: payload.password,
|
||||
verify_code: payload.verifyCode || undefined,
|
||||
...(payload.turnstileToken ? { turnstile_token: payload.turnstileToken } : {}),
|
||||
...(payload.tencentCaptchaTicket
|
||||
? {
|
||||
tencent_captcha_ticket: payload.tencentCaptchaTicket,
|
||||
tencent_captcha_randstr: payload.tencentCaptchaRandstr
|
||||
}
|
||||
: {}),
|
||||
invitation_code: payload.invitationCode || undefined,
|
||||
...oauthAffiliatePayload(loadOAuthAffiliateCode()),
|
||||
...serializeAdoptionDecision(currentAdoptionDecision())
|
||||
|
||||
@@ -79,10 +79,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Turnstile Widget -->
|
||||
<div v-if="turnstileEnabled && turnstileSiteKey">
|
||||
<div v-if="captchaEnabled">
|
||||
<TurnstileWidget
|
||||
ref="turnstileRef"
|
||||
:site-key="turnstileSiteKey"
|
||||
:turnstile-enabled="turnstileEnabled"
|
||||
:turnstile-site-key="turnstileSiteKey"
|
||||
:tencent-enabled="tencentCaptchaEnabled"
|
||||
:tencent-app-id="tencentCaptchaAppId"
|
||||
@verify="onTurnstileVerify"
|
||||
@expire="onTurnstileExpire"
|
||||
@error="onTurnstileError"
|
||||
@@ -156,28 +159,33 @@
|
||||
:github-enabled="githubOAuthEnabled"
|
||||
:google-enabled="googleOAuthEnabled"
|
||||
:show-divider="false"
|
||||
@start="handleOAuthStart"
|
||||
/>
|
||||
|
||||
<LinuxDoOAuthSection
|
||||
v-if="linuxdoOAuthEnabled"
|
||||
:disabled="authActionDisabled"
|
||||
:show-divider="false"
|
||||
@start="handleOAuthStart"
|
||||
/>
|
||||
<DingTalkOAuthSection
|
||||
v-if="dingtalkOAuthEnabled"
|
||||
:disabled="authActionDisabled"
|
||||
:show-divider="false"
|
||||
@start="handleOAuthStart"
|
||||
/>
|
||||
<WechatOAuthSection
|
||||
v-if="wechatOAuthEnabled"
|
||||
:disabled="authActionDisabled"
|
||||
:show-divider="false"
|
||||
@start="handleOAuthStart"
|
||||
/>
|
||||
<OidcOAuthSection
|
||||
v-if="oidcOAuthEnabled"
|
||||
:disabled="authActionDisabled"
|
||||
:provider-name="oidcOAuthProviderName"
|
||||
:show-divider="false"
|
||||
@start="handleOAuthStart"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
@@ -221,10 +229,21 @@ import EmailOAuthButtons from '@/components/auth/EmailOAuthButtons.vue'
|
||||
import LoginAgreementPrompt from '@/components/auth/LoginAgreementPrompt.vue'
|
||||
import TotpLoginModal from '@/components/auth/TotpLoginModal.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||
import TurnstileWidget from '@/components/CaptchaChallenge.vue'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
import { getPublicSettings, isTotp2FARequired, isWeChatWebOAuthEnabled } from '@/api/auth'
|
||||
import type { LoginAgreementDocument, TotpLoginResponse } from '@/types'
|
||||
import {
|
||||
buildOAuthLoginStartURL,
|
||||
getPublicSettings,
|
||||
isTotp2FARequired,
|
||||
isWeChatWebOAuthEnabled,
|
||||
startOAuthLogin,
|
||||
type OAuthLoginStart
|
||||
} from '@/api/auth'
|
||||
import type {
|
||||
LoginAgreementDocument,
|
||||
TencentCaptchaRequestProof,
|
||||
TotpLoginResponse
|
||||
} from '@/types'
|
||||
import { extractI18nErrorMessage } from '@/utils/apiError'
|
||||
import { clearAllAffiliateReferralCodes } from '@/utils/oauthAffiliate'
|
||||
|
||||
@@ -248,6 +267,8 @@ const publicSettingsLoaded = ref<boolean>(false)
|
||||
// Public settings
|
||||
const turnstileEnabled = ref<boolean>(false)
|
||||
const turnstileSiteKey = ref<string>('')
|
||||
const tencentCaptchaEnabled = ref<boolean>(false)
|
||||
const tencentCaptchaAppId = ref<string>('')
|
||||
const linuxdoOAuthEnabled = ref<boolean>(false)
|
||||
const dingtalkOAuthEnabled = ref<boolean>(false)
|
||||
const wechatOAuthEnabled = ref<boolean>(false)
|
||||
@@ -269,6 +290,12 @@ const showAgreementModal = ref<boolean>(false)
|
||||
// Turnstile
|
||||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||||
const turnstileToken = ref<string>('')
|
||||
const tencentCaptchaRandstr = ref<string>('')
|
||||
const captchaEnabled = computed(
|
||||
() =>
|
||||
(turnstileEnabled.value && Boolean(turnstileSiteKey.value)) ||
|
||||
(tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value))
|
||||
)
|
||||
|
||||
// 2FA state
|
||||
const show2FAModal = ref<boolean>(false)
|
||||
@@ -335,6 +362,8 @@ onMounted(async () => {
|
||||
const settings = await getPublicSettings()
|
||||
turnstileEnabled.value = settings.turnstile_enabled
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||
tencentCaptchaEnabled.value = settings.tencent_captcha_enabled === true
|
||||
tencentCaptchaAppId.value = settings.tencent_captcha_app_id || ''
|
||||
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
|
||||
dingtalkOAuthEnabled.value = settings.dingtalk_oauth_enabled ?? false
|
||||
wechatOAuthEnabled.value = isWeChatWebOAuthEnabled(settings)
|
||||
@@ -420,21 +449,42 @@ function rejectLoginAgreement(): void {
|
||||
|
||||
// ==================== Turnstile Handlers ====================
|
||||
|
||||
function onTurnstileVerify(token: string): void {
|
||||
function onTurnstileVerify(token: string, randstr = ''): void {
|
||||
turnstileToken.value = token
|
||||
tencentCaptchaRandstr.value = randstr
|
||||
errors.turnstile = ''
|
||||
}
|
||||
|
||||
function onTurnstileExpire(): void {
|
||||
turnstileToken.value = ''
|
||||
tencentCaptchaRandstr.value = ''
|
||||
errors.turnstile = t('auth.turnstileExpired')
|
||||
}
|
||||
|
||||
function onTurnstileError(): void {
|
||||
turnstileToken.value = ''
|
||||
tencentCaptchaRandstr.value = ''
|
||||
errors.turnstile = t('auth.turnstileFailed')
|
||||
}
|
||||
|
||||
function resetCaptchaProof(): void {
|
||||
turnstileRef.value?.reset()
|
||||
turnstileToken.value = ''
|
||||
tencentCaptchaRandstr.value = ''
|
||||
errors.turnstile = ''
|
||||
}
|
||||
|
||||
async function acquireTencentProof(): Promise<boolean> {
|
||||
if (!tencentCaptchaEnabled.value) return true
|
||||
|
||||
const proof = await turnstileRef.value?.verifyTencent()
|
||||
if (!proof) return false
|
||||
|
||||
turnstileToken.value = proof.ticket
|
||||
tencentCaptchaRandstr.value = proof.randstr
|
||||
return true
|
||||
}
|
||||
|
||||
// ==================== Validation ====================
|
||||
|
||||
function validateForm(): boolean {
|
||||
@@ -491,6 +541,10 @@ async function handleLogin(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
if (!(await acquireTencentProof())) {
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
@@ -498,7 +552,11 @@ async function handleLogin(): Promise<void> {
|
||||
const response = await authStore.login({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined
|
||||
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined,
|
||||
tencent_captcha_ticket: tencentCaptchaEnabled.value ? turnstileToken.value : undefined,
|
||||
tencent_captcha_randstr: tencentCaptchaEnabled.value
|
||||
? tencentCaptchaRandstr.value
|
||||
: undefined
|
||||
})
|
||||
|
||||
// Check if 2FA is required
|
||||
@@ -519,17 +577,14 @@ async function handleLogin(): Promise<void> {
|
||||
const redirectTo = (router.currentRoute.value.query.redirect as string) || '/dashboard'
|
||||
await router.push(redirectTo)
|
||||
} catch (error: unknown) {
|
||||
// Reset Turnstile on error
|
||||
if (turnstileRef.value) {
|
||||
turnstileRef.value.reset()
|
||||
turnstileToken.value = ''
|
||||
}
|
||||
|
||||
errorMessage.value = extractI18nErrorMessage(error, t, 'auth.errors', t('auth.loginFailed'))
|
||||
|
||||
// Also show error toast
|
||||
appStore.showError(errorMessage.value)
|
||||
} finally {
|
||||
if (captchaEnabled.value) {
|
||||
resetCaptchaProof()
|
||||
}
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
@@ -545,7 +600,17 @@ async function handlePasskeyLogin(): Promise<void> {
|
||||
|
||||
passkeyLoading.value = true
|
||||
try {
|
||||
await authStore.loginWithPasskey()
|
||||
let proof: TencentCaptchaRequestProof | undefined
|
||||
if (tencentCaptchaEnabled.value) {
|
||||
const result = await turnstileRef.value?.verifyTencent()
|
||||
if (!result) return
|
||||
proof = {
|
||||
tencent_captcha_ticket: result.ticket,
|
||||
tencent_captcha_randstr: result.randstr
|
||||
}
|
||||
}
|
||||
|
||||
await authStore.loginWithPasskey(proof)
|
||||
clearAllAffiliateReferralCodes()
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
const redirectTo = (router.currentRoute.value.query.redirect as string) || '/dashboard'
|
||||
@@ -557,10 +622,45 @@ async function handlePasskeyLogin(): Promise<void> {
|
||||
errorMessage.value = extractI18nErrorMessage(error, t, 'auth.errors', fallback)
|
||||
appStore.showError(errorMessage.value)
|
||||
} finally {
|
||||
if (tencentCaptchaEnabled.value) {
|
||||
resetCaptchaProof()
|
||||
}
|
||||
passkeyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOAuthStart(request: OAuthLoginStart): Promise<void> {
|
||||
if (authActionDisabled.value) return
|
||||
|
||||
if (!tencentCaptchaEnabled.value) {
|
||||
window.location.href = buildOAuthLoginStartURL(request)
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
const proof = await turnstileRef.value?.verifyTencent()
|
||||
if (!proof) return
|
||||
|
||||
const result = await startOAuthLogin(request, {
|
||||
tencent_captcha_ticket: proof.ticket,
|
||||
tencent_captcha_randstr: proof.randstr
|
||||
})
|
||||
window.location.href = result.authorize_url
|
||||
} catch (error: unknown) {
|
||||
errorMessage.value = extractI18nErrorMessage(
|
||||
error,
|
||||
t,
|
||||
'auth.errors',
|
||||
t('auth.turnstileFailed')
|
||||
)
|
||||
appStore.showError(errorMessage.value)
|
||||
} finally {
|
||||
resetCaptchaProof()
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 2FA Handlers ====================
|
||||
|
||||
async function handle2FAVerify(code: string): Promise<void> {
|
||||
|
||||
@@ -705,6 +705,13 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
|
||||
email: payload.email,
|
||||
password: payload.password,
|
||||
verify_code: payload.verifyCode || undefined,
|
||||
...(payload.turnstileToken ? { turnstile_token: payload.turnstileToken } : {}),
|
||||
...(payload.tencentCaptchaTicket
|
||||
? {
|
||||
tencent_captcha_ticket: payload.tencentCaptchaTicket,
|
||||
tencent_captcha_randstr: payload.tencentCaptchaRandstr
|
||||
}
|
||||
: {}),
|
||||
invitation_code: payload.invitationCode || undefined,
|
||||
...oauthAffiliatePayload(loadOAuthAffiliateCode()),
|
||||
...serializeAdoptionDecision(currentAdoptionDecision())
|
||||
|
||||
@@ -204,10 +204,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Turnstile Widget -->
|
||||
<div v-if="turnstileEnabled && turnstileSiteKey" data-testid="registration-turnstile">
|
||||
<div v-if="captchaEnabled" data-testid="registration-turnstile">
|
||||
<TurnstileWidget
|
||||
ref="turnstileRef"
|
||||
:site-key="turnstileSiteKey"
|
||||
:turnstile-enabled="turnstileEnabled"
|
||||
:turnstile-site-key="turnstileSiteKey"
|
||||
:tencent-enabled="tencentCaptchaEnabled"
|
||||
:tencent-app-id="tencentCaptchaAppId"
|
||||
@verify="onTurnstileVerify"
|
||||
@expire="onTurnstileExpire"
|
||||
@error="onTurnstileError"
|
||||
@@ -279,6 +282,7 @@
|
||||
:github-enabled="githubOAuthEnabled"
|
||||
:google-enabled="googleOAuthEnabled"
|
||||
:show-divider="false"
|
||||
@start="handleOAuthStart"
|
||||
/>
|
||||
|
||||
<LinuxDoOAuthSection
|
||||
@@ -286,12 +290,14 @@
|
||||
:disabled="registrationActionDisabled"
|
||||
:aff-code="formData.aff_code"
|
||||
:show-divider="false"
|
||||
@start="handleOAuthStart"
|
||||
/>
|
||||
<WechatOAuthSection
|
||||
v-if="wechatOAuthEnabled"
|
||||
:disabled="registrationActionDisabled"
|
||||
:aff-code="formData.aff_code"
|
||||
:show-divider="false"
|
||||
@start="handleOAuthStart"
|
||||
/>
|
||||
<OidcOAuthSection
|
||||
v-if="oidcOAuthEnabled"
|
||||
@@ -299,6 +305,7 @@
|
||||
:provider-name="oidcOAuthProviderName"
|
||||
:aff-code="formData.aff_code"
|
||||
:show-divider="false"
|
||||
@start="handleOAuthStart"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -329,15 +336,19 @@ import WechatOAuthSection from '@/components/auth/WechatOAuthSection.vue'
|
||||
import EmailOAuthButtons from '@/components/auth/EmailOAuthButtons.vue'
|
||||
import LoginAgreementPrompt from '@/components/auth/LoginAgreementPrompt.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||
import TurnstileWidget from '@/components/CaptchaChallenge.vue'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
import {
|
||||
buildOAuthLoginStartURL,
|
||||
getPublicSettings,
|
||||
isWeChatWebOAuthEnabled,
|
||||
startOAuthLogin,
|
||||
type OAuthLoginStart,
|
||||
validatePromoCode,
|
||||
validateInvitationCode
|
||||
} from '@/api/auth'
|
||||
import { buildAuthErrorMessage } from '@/utils/authError'
|
||||
import { extractI18nErrorMessage } from '@/utils/apiError'
|
||||
import {
|
||||
formatRegistrationEmailSuffixWhitelistForMessage,
|
||||
isRegistrationEmailSuffixAllowed,
|
||||
@@ -375,6 +386,8 @@ const invitationCodeEnabled = ref<boolean>(false)
|
||||
const affiliateEnabled = ref<boolean>(false)
|
||||
const turnstileEnabled = ref<boolean>(false)
|
||||
const turnstileSiteKey = ref<string>('')
|
||||
const tencentCaptchaEnabled = ref<boolean>(false)
|
||||
const tencentCaptchaAppId = ref<string>('')
|
||||
const siteName = ref<string>('Sub2API')
|
||||
const linuxdoOAuthEnabled = ref<boolean>(false)
|
||||
const wechatOAuthEnabled = ref<boolean>(false)
|
||||
@@ -394,6 +407,12 @@ const showAgreementModal = ref<boolean>(false)
|
||||
// Turnstile
|
||||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||||
const turnstileToken = ref<string>('')
|
||||
const tencentCaptchaRandstr = ref<string>('')
|
||||
const captchaEnabled = computed(
|
||||
() =>
|
||||
(turnstileEnabled.value && Boolean(turnstileSiteKey.value)) ||
|
||||
(tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value))
|
||||
)
|
||||
|
||||
// Promo code validation
|
||||
const promoValidating = ref<boolean>(false)
|
||||
@@ -484,6 +503,8 @@ onMounted(async () => {
|
||||
affiliateEnabled.value = settings.affiliate_enabled
|
||||
turnstileEnabled.value = settings.turnstile_enabled
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||
tencentCaptchaEnabled.value = settings.tencent_captcha_enabled === true
|
||||
tencentCaptchaAppId.value = settings.tencent_captcha_app_id || ''
|
||||
siteName.value = settings.site_name || 'Sub2API'
|
||||
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
|
||||
wechatOAuthEnabled.value = isWeChatWebOAuthEnabled(settings)
|
||||
@@ -732,21 +753,74 @@ function getInvitationErrorMessage(errorCode?: string): string {
|
||||
|
||||
// ==================== Turnstile Handlers ====================
|
||||
|
||||
function onTurnstileVerify(token: string): void {
|
||||
function onTurnstileVerify(token: string, randstr = ''): void {
|
||||
turnstileToken.value = token
|
||||
tencentCaptchaRandstr.value = randstr
|
||||
errors.turnstile = ''
|
||||
}
|
||||
|
||||
function onTurnstileExpire(): void {
|
||||
turnstileToken.value = ''
|
||||
tencentCaptchaRandstr.value = ''
|
||||
errors.turnstile = t('auth.turnstileExpired')
|
||||
}
|
||||
|
||||
function onTurnstileError(): void {
|
||||
turnstileToken.value = ''
|
||||
tencentCaptchaRandstr.value = ''
|
||||
errors.turnstile = t('auth.turnstileFailed')
|
||||
}
|
||||
|
||||
function resetCaptchaProof(): void {
|
||||
turnstileRef.value?.reset()
|
||||
turnstileToken.value = ''
|
||||
tencentCaptchaRandstr.value = ''
|
||||
errors.turnstile = ''
|
||||
}
|
||||
|
||||
async function acquireTencentProof(): Promise<boolean> {
|
||||
if (!tencentCaptchaEnabled.value) return true
|
||||
|
||||
const proof = await turnstileRef.value?.verifyTencent()
|
||||
if (!proof) return false
|
||||
|
||||
turnstileToken.value = proof.ticket
|
||||
tencentCaptchaRandstr.value = proof.randstr
|
||||
return true
|
||||
}
|
||||
|
||||
async function handleOAuthStart(request: OAuthLoginStart): Promise<void> {
|
||||
if (registrationActionDisabled.value) return
|
||||
|
||||
if (!tencentCaptchaEnabled.value) {
|
||||
window.location.href = buildOAuthLoginStartURL(request)
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
const proof = await turnstileRef.value?.verifyTencent()
|
||||
if (!proof) return
|
||||
|
||||
const result = await startOAuthLogin(request, {
|
||||
tencent_captcha_ticket: proof.ticket,
|
||||
tencent_captcha_randstr: proof.randstr
|
||||
})
|
||||
window.location.href = result.authorize_url
|
||||
} catch (error: unknown) {
|
||||
errorMessage.value = extractI18nErrorMessage(
|
||||
error,
|
||||
t,
|
||||
'auth.errors',
|
||||
t('auth.turnstileFailed')
|
||||
)
|
||||
appStore.showError(errorMessage.value)
|
||||
} finally {
|
||||
resetCaptchaProof()
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Validation ====================
|
||||
|
||||
function validateEmail(email: string): boolean {
|
||||
@@ -876,6 +950,10 @@ async function handleRegister(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
if (!(await acquireTencentProof())) {
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
@@ -892,7 +970,9 @@ async function handleRegister(): Promise<void> {
|
||||
JSON.stringify({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
turnstile_token: turnstileToken.value,
|
||||
turnstile_token: turnstileEnabled.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,
|
||||
invitation_code: formData.invitation_code || undefined,
|
||||
...(affCode ? { aff_code: affCode } : {})
|
||||
@@ -909,6 +989,8 @@ async function handleRegister(): Promise<void> {
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
turnstile_token: turnstileEnabled.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,
|
||||
invitation_code: formData.invitation_code || undefined,
|
||||
...(affCode ? { aff_code: affCode } : {})
|
||||
@@ -921,12 +1003,6 @@ async function handleRegister(): Promise<void> {
|
||||
// Redirect to dashboard
|
||||
await router.push('/dashboard')
|
||||
} catch (error: unknown) {
|
||||
// Reset Turnstile on error
|
||||
if (turnstileRef.value) {
|
||||
turnstileRef.value.reset()
|
||||
turnstileToken.value = ''
|
||||
}
|
||||
|
||||
// Handle registration error
|
||||
errorMessage.value = buildAuthErrorMessage(error, {
|
||||
fallback: t('auth.registrationFailed')
|
||||
@@ -935,6 +1011,9 @@ async function handleRegister(): Promise<void> {
|
||||
// Also show error toast
|
||||
appStore.showError(errorMessage.value)
|
||||
} finally {
|
||||
if (captchaEnabled.value) {
|
||||
resetCaptchaProof()
|
||||
}
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,7 +562,7 @@ function resolveWeChatStartURL(intent: 'bind_current_user' | 'adopt_existing_use
|
||||
intent,
|
||||
})
|
||||
|
||||
return `${normalized}/auth/oauth/wechat/start?${params.toString()}`
|
||||
return `${normalized}/auth/oauth/wechat/bind/start?${params.toString()}`
|
||||
}
|
||||
|
||||
function buildExistingAccountResumePath(): string | null {
|
||||
@@ -915,6 +915,13 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
|
||||
email: payload.email,
|
||||
password: payload.password,
|
||||
verify_code: payload.verifyCode || undefined,
|
||||
...(payload.turnstileToken ? { turnstile_token: payload.turnstileToken } : {}),
|
||||
...(payload.tencentCaptchaTicket
|
||||
? {
|
||||
tencent_captcha_ticket: payload.tencentCaptchaTicket,
|
||||
tencent_captcha_randstr: payload.tencentCaptchaRandstr
|
||||
}
|
||||
: {}),
|
||||
invitation_code: payload.invitationCode || undefined,
|
||||
...oauthAffiliatePayload(loadOAuthAffiliateCode()),
|
||||
...serializeAdoptionDecision(currentAdoptionDecision())
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import EmailVerifyView from '@/views/auth/EmailVerifyView.vue'
|
||||
@@ -16,6 +17,8 @@ const {
|
||||
persistOAuthTokenContextMock,
|
||||
apiClientPostMock,
|
||||
authStoreState,
|
||||
createTurnstileResetMock,
|
||||
verifyTencentMock,
|
||||
} = vi.hoisted(() => ({
|
||||
pushMock: vi.fn(),
|
||||
showSuccessMock: vi.fn(),
|
||||
@@ -29,6 +32,8 @@ const {
|
||||
sendPendingOAuthVerifyCodeMock: vi.fn(),
|
||||
persistOAuthTokenContextMock: vi.fn(),
|
||||
apiClientPostMock: vi.fn(),
|
||||
createTurnstileResetMock: vi.fn(),
|
||||
verifyTencentMock: vi.fn(),
|
||||
authStoreState: {
|
||||
pendingAuthSession: null as null | {
|
||||
token: string
|
||||
@@ -110,6 +115,8 @@ describe('EmailVerifyView', () => {
|
||||
sendPendingOAuthVerifyCodeMock.mockReset()
|
||||
persistOAuthTokenContextMock.mockReset()
|
||||
apiClientPostMock.mockReset()
|
||||
createTurnstileResetMock.mockReset()
|
||||
verifyTencentMock.mockReset()
|
||||
authStoreState.pendingAuthSession = null
|
||||
sessionStorage.clear()
|
||||
localStorage.clear()
|
||||
@@ -125,6 +132,67 @@ describe('EmailVerifyView', () => {
|
||||
setTokenMock.mockResolvedValue({})
|
||||
})
|
||||
|
||||
it('acquires a fresh Tencent proof for each resend action', async () => {
|
||||
getPublicSettingsMock.mockResolvedValue({
|
||||
turnstile_enabled: false,
|
||||
turnstile_site_key: '',
|
||||
tencent_captcha_enabled: true,
|
||||
tencent_captcha_app_id: 'tencent-app-id',
|
||||
site_name: 'Sub2API',
|
||||
registration_email_suffix_whitelist: [],
|
||||
})
|
||||
sendVerifyCodeMock.mockResolvedValue({ countdown: 0 })
|
||||
verifyTencentMock
|
||||
.mockResolvedValueOnce({ ticket: 'ticket-1', randstr: '@rand-1' })
|
||||
.mockResolvedValueOnce({ ticket: 'ticket-2', randstr: '@rand-2' })
|
||||
sessionStorage.setItem(
|
||||
'register_data',
|
||||
JSON.stringify({
|
||||
email: 'fresh@example.com',
|
||||
password: 'secret-123',
|
||||
tencent_captcha_ticket: 'initial-ticket',
|
||||
tencent_captcha_randstr: '@initial-rand',
|
||||
})
|
||||
)
|
||||
|
||||
const CaptchaChallengeStub = defineComponent({
|
||||
setup(_, { expose }) {
|
||||
expose({ verifyTencent: verifyTencentMock, reset: createTurnstileResetMock })
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
const wrapper = mount(EmailVerifyView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /><slot name="footer" /></div>' },
|
||||
Icon: true,
|
||||
TurnstileWidget: CaptchaChallengeStub,
|
||||
transition: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
const resendButton = () => wrapper.findAll('button').find((button) =>
|
||||
button.text().includes('auth.clickToResend')
|
||||
)!
|
||||
|
||||
await resendButton().trigger('click')
|
||||
await flushPromises()
|
||||
await resendButton().trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(verifyTencentMock).toHaveBeenCalledTimes(2)
|
||||
expect(sendVerifyCodeMock).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||
tencent_captcha_ticket: 'ticket-1',
|
||||
tencent_captcha_randstr: '@rand-1',
|
||||
}))
|
||||
expect(sendVerifyCodeMock).toHaveBeenNthCalledWith(3, expect.objectContaining({
|
||||
tencent_captcha_ticket: 'ticket-2',
|
||||
tencent_captcha_randstr: '@rand-2',
|
||||
}))
|
||||
})
|
||||
|
||||
it('uses the pending oauth verify-code endpoint when register data carries a pending auth session', async () => {
|
||||
authStoreState.pendingAuthSession = {
|
||||
token: 'pending-token-1',
|
||||
@@ -160,6 +228,44 @@ describe('EmailVerifyView', () => {
|
||||
expect(sendVerifyCodeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires a fresh captcha proof after the initial send-code request fails', async () => {
|
||||
getPublicSettingsMock.mockResolvedValue({
|
||||
turnstile_enabled: true,
|
||||
turnstile_site_key: 'site-key',
|
||||
site_name: 'Sub2API',
|
||||
registration_email_suffix_whitelist: [],
|
||||
})
|
||||
sendVerifyCodeMock.mockRejectedValue(new Error('send failed'))
|
||||
sessionStorage.setItem(
|
||||
'register_data',
|
||||
JSON.stringify({
|
||||
email: 'fresh@example.com',
|
||||
password: 'secret-123',
|
||||
turnstile_token: 'initial-proof',
|
||||
})
|
||||
)
|
||||
|
||||
const wrapper = mount(EmailVerifyView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /><slot name="footer" /></div>' },
|
||||
Icon: true,
|
||||
TurnstileWidget: {
|
||||
template: '<button data-testid="resend-captcha" @click="$emit(\'verify\', \'fresh-proof\')">verify</button>',
|
||||
},
|
||||
transition: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(sendVerifyCodeMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
turnstile_token: 'initial-proof',
|
||||
}))
|
||||
expect(wrapper.find('[data-testid="resend-captcha"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('skips the registration email suffix whitelist for pending oauth verification', async () => {
|
||||
authStoreState.pendingAuthSession = {
|
||||
token: 'pending-token-2',
|
||||
@@ -350,6 +456,135 @@ describe('EmailVerifyView', () => {
|
||||
expect(registerMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires and submits a fresh turnstile token for pending oauth account creation', async () => {
|
||||
authStoreState.pendingAuthSession = {
|
||||
token: 'pending-token-3',
|
||||
token_field: 'pending_auth_token',
|
||||
provider: 'oidc',
|
||||
redirect: '/profile',
|
||||
}
|
||||
getPublicSettingsMock.mockResolvedValue({
|
||||
turnstile_enabled: true,
|
||||
turnstile_site_key: 'site-key',
|
||||
site_name: 'Sub2API',
|
||||
registration_email_suffix_whitelist: ['allowed.com'],
|
||||
})
|
||||
sessionStorage.setItem(
|
||||
'register_data',
|
||||
JSON.stringify({
|
||||
email: 'fresh@example.com',
|
||||
password: 'secret-123',
|
||||
turnstile_token: 'send-code-token',
|
||||
})
|
||||
)
|
||||
apiClientPostMock.mockResolvedValue({
|
||||
data: {
|
||||
access_token: 'oauth-access-token',
|
||||
refresh_token: 'oauth-refresh-token',
|
||||
expires_in: 3600,
|
||||
token_type: 'Bearer',
|
||||
},
|
||||
})
|
||||
|
||||
const wrapper = mount(EmailVerifyView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /><slot name="footer" /></div>' },
|
||||
Icon: true,
|
||||
TurnstileWidget: {
|
||||
template: '<button data-testid="create-turnstile" @click="$emit(\'verify\', \'create-token\')">verify</button>',
|
||||
methods: {
|
||||
reset: createTurnstileResetMock,
|
||||
},
|
||||
},
|
||||
transition: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(sendPendingOAuthVerifyCodeMock).toHaveBeenCalledWith({
|
||||
email: 'fresh@example.com',
|
||||
pending_auth_token: 'pending-token-3',
|
||||
turnstile_token: 'send-code-token',
|
||||
})
|
||||
|
||||
await wrapper.get('#code').setValue('123456')
|
||||
expect(wrapper.get('button[type="submit"]').attributes('disabled')).toBeDefined()
|
||||
|
||||
await wrapper.get('[data-testid="create-turnstile"]').trigger('click')
|
||||
expect(wrapper.get('button[type="submit"]').attributes('disabled')).toBeUndefined()
|
||||
|
||||
await wrapper.get('form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(apiClientPostMock).toHaveBeenCalledWith('/auth/oauth/pending/create-account', {
|
||||
email: 'fresh@example.com',
|
||||
password: 'secret-123',
|
||||
verify_code: '123456',
|
||||
turnstile_token: 'create-token',
|
||||
})
|
||||
expect(setTokenMock).toHaveBeenCalledWith('oauth-access-token')
|
||||
})
|
||||
|
||||
it('resets the pending oauth create-account turnstile after submit failure', async () => {
|
||||
authStoreState.pendingAuthSession = {
|
||||
token: 'pending-token-4',
|
||||
token_field: 'pending_auth_token',
|
||||
provider: 'oidc',
|
||||
redirect: '/profile',
|
||||
}
|
||||
getPublicSettingsMock.mockResolvedValue({
|
||||
turnstile_enabled: true,
|
||||
turnstile_site_key: 'site-key',
|
||||
site_name: 'Sub2API',
|
||||
registration_email_suffix_whitelist: ['allowed.com'],
|
||||
})
|
||||
sessionStorage.setItem(
|
||||
'register_data',
|
||||
JSON.stringify({
|
||||
email: 'fresh@example.com',
|
||||
password: 'secret-123',
|
||||
turnstile_token: 'send-code-token',
|
||||
})
|
||||
)
|
||||
apiClientPostMock.mockRejectedValue(new Error('invalid verify code'))
|
||||
|
||||
const wrapper = mount(EmailVerifyView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /><slot name="footer" /></div>' },
|
||||
Icon: true,
|
||||
TurnstileWidget: {
|
||||
template: '<button data-testid="create-turnstile" @click="$emit(\'verify\', \'create-token\')">verify</button>',
|
||||
methods: {
|
||||
reset: createTurnstileResetMock,
|
||||
},
|
||||
},
|
||||
transition: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
await wrapper.get('#code').setValue('123456')
|
||||
await wrapper.get('[data-testid="create-turnstile"]').trigger('click')
|
||||
expect(wrapper.get('button[type="submit"]').attributes('disabled')).toBeUndefined()
|
||||
|
||||
await wrapper.get('form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(apiClientPostMock).toHaveBeenCalledWith('/auth/oauth/pending/create-account', {
|
||||
email: 'fresh@example.com',
|
||||
password: 'secret-123',
|
||||
verify_code: '123456',
|
||||
turnstile_token: 'create-token',
|
||||
})
|
||||
expect(createTurnstileResetMock).toHaveBeenCalled()
|
||||
expect(wrapper.get('button[type="submit"]').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('returns to the oauth callback flow when pending account creation becomes bind-login', async () => {
|
||||
authStoreState.pendingAuthSession = {
|
||||
token: '',
|
||||
@@ -447,10 +682,70 @@ describe('EmailVerifyView', () => {
|
||||
password: 'secret-456',
|
||||
verify_code: '654321',
|
||||
turnstile_token: undefined,
|
||||
tencent_captcha_ticket: undefined,
|
||||
tencent_captcha_randstr: undefined,
|
||||
promo_code: 'PROMO',
|
||||
invitation_code: 'INVITE',
|
||||
})
|
||||
expect(apiClientPostMock).not.toHaveBeenCalled()
|
||||
expect(pushMock).toHaveBeenCalledWith('/dashboard')
|
||||
})
|
||||
|
||||
it('does not require another Tencent proof for final email registration', async () => {
|
||||
getPublicSettingsMock.mockResolvedValue({
|
||||
turnstile_enabled: false,
|
||||
turnstile_site_key: '',
|
||||
tencent_captcha_enabled: true,
|
||||
tencent_captcha_app_id: 'tencent-app-id',
|
||||
site_name: 'Sub2API',
|
||||
registration_email_suffix_whitelist: [],
|
||||
})
|
||||
sessionStorage.setItem(
|
||||
'register_data',
|
||||
JSON.stringify({
|
||||
email: 'normal@example.com',
|
||||
password: 'secret-456',
|
||||
tencent_captcha_ticket: 'send-code-ticket',
|
||||
tencent_captcha_randstr: '@send-code-rand',
|
||||
})
|
||||
)
|
||||
registerMock.mockResolvedValue({})
|
||||
|
||||
const wrapper = mount(EmailVerifyView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /><slot name="footer" /></div>' },
|
||||
Icon: true,
|
||||
TurnstileWidget: {
|
||||
template: '<span />',
|
||||
methods: {
|
||||
reset: createTurnstileResetMock,
|
||||
},
|
||||
},
|
||||
transition: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
expect(sendVerifyCodeMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
tencent_captcha_ticket: 'send-code-ticket',
|
||||
tencent_captcha_randstr: '@send-code-rand',
|
||||
}))
|
||||
expect(JSON.parse(sessionStorage.getItem('register_data') || '{}')).toEqual({
|
||||
email: 'normal@example.com',
|
||||
password: 'secret-456',
|
||||
})
|
||||
|
||||
await wrapper.get('#code').setValue('654321')
|
||||
await wrapper.get('form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(registerMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
email: 'normal@example.com',
|
||||
verify_code: '654321',
|
||||
tencent_captcha_ticket: undefined,
|
||||
tencent_captcha_randstr: undefined,
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import LoginView from '@/views/auth/LoginView.vue'
|
||||
|
||||
const loginMock = vi.fn()
|
||||
const loginWithPasskeyMock = vi.fn()
|
||||
const getPublicSettingsMock = vi.fn()
|
||||
const startOAuthLoginMock = vi.fn()
|
||||
const verifyTencentMock = vi.fn()
|
||||
const captchaResetMock = vi.fn()
|
||||
const locationState = { href: 'http://localhost/login' }
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({
|
||||
currentRoute: { value: { query: {} } },
|
||||
push: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useAuthStore: () => ({
|
||||
login: (...args: unknown[]) => loginMock(...args),
|
||||
loginWithPasskey: (...args: unknown[]) => loginWithPasskeyMock(...args)
|
||||
}),
|
||||
useAppStore: () => ({
|
||||
showError: vi.fn(),
|
||||
showSuccess: vi.fn(),
|
||||
showWarning: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/api/auth', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/api/auth')>('@/api/auth')
|
||||
return {
|
||||
...actual,
|
||||
getPublicSettings: (...args: unknown[]) => getPublicSettingsMock(...args),
|
||||
startOAuthLogin: (...args: unknown[]) => startOAuthLoginMock(...args),
|
||||
isTotp2FARequired: () => false,
|
||||
isWeChatWebOAuthEnabled: () => false
|
||||
}
|
||||
})
|
||||
|
||||
const CaptchaChallengeStub = defineComponent({
|
||||
setup(_, { expose }) {
|
||||
expose({
|
||||
verifyTencent: verifyTencentMock,
|
||||
reset: captchaResetMock
|
||||
})
|
||||
return () => h('div')
|
||||
}
|
||||
})
|
||||
|
||||
const OAuthButtonStub = defineComponent({
|
||||
emits: ['start'],
|
||||
setup(_, { emit }) {
|
||||
return () => h('button', {
|
||||
type: 'button',
|
||||
'data-testid': 'oauth-start',
|
||||
onClick: () => emit('start', {
|
||||
provider: 'github',
|
||||
params: { redirect: '/dashboard' }
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function mountLogin() {
|
||||
return mount(LoginView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /><slot name="footer" /></div>' },
|
||||
RouterLink: true,
|
||||
TurnstileWidget: CaptchaChallengeStub,
|
||||
Icon: true,
|
||||
LoginAgreementPrompt: true,
|
||||
TotpLoginModal: true,
|
||||
EmailOAuthButtons: OAuthButtonStub,
|
||||
LinuxDoOAuthSection: true,
|
||||
DingTalkOAuthSection: true,
|
||||
OidcOAuthSection: true,
|
||||
WechatOAuthSection: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('Tencent captcha action gate', () => {
|
||||
beforeEach(() => {
|
||||
loginMock.mockReset()
|
||||
loginWithPasskeyMock.mockReset()
|
||||
getPublicSettingsMock.mockReset()
|
||||
startOAuthLoginMock.mockReset()
|
||||
verifyTencentMock.mockReset()
|
||||
captchaResetMock.mockReset()
|
||||
getPublicSettingsMock.mockResolvedValue({
|
||||
turnstile_enabled: false,
|
||||
turnstile_site_key: '',
|
||||
tencent_captcha_enabled: true,
|
||||
tencent_captcha_app_id: 'tencent-app-id',
|
||||
backend_mode_enabled: false,
|
||||
password_reset_enabled: false,
|
||||
passkey_enabled: true,
|
||||
github_oauth_enabled: true,
|
||||
google_oauth_enabled: false
|
||||
})
|
||||
loginMock.mockResolvedValue({})
|
||||
loginWithPasskeyMock.mockResolvedValue({})
|
||||
startOAuthLoginMock.mockResolvedValue({ authorize_url: 'https://github.example/authorize' })
|
||||
verifyTencentMock.mockResolvedValue({ ticket: 'ticket-1', randstr: '@rand-1' })
|
||||
Object.defineProperty(window, 'PublicKeyCredential', {
|
||||
configurable: true,
|
||||
value: class PublicKeyCredential {}
|
||||
})
|
||||
locationState.href = 'http://localhost/login'
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: locationState
|
||||
})
|
||||
})
|
||||
|
||||
it('clicking login opens Tencent captcha before calling login', async () => {
|
||||
const wrapper = mountLogin()
|
||||
await flushPromises()
|
||||
await wrapper.get('#email').setValue('user@example.com')
|
||||
await wrapper.get('#password').setValue('secret-123')
|
||||
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(verifyTencentMock).toHaveBeenCalledOnce()
|
||||
expect(loginMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
tencent_captcha_ticket: 'ticket-1',
|
||||
tencent_captcha_randstr: '@rand-1'
|
||||
}))
|
||||
})
|
||||
|
||||
it('does not call login when Tencent captcha is closed', async () => {
|
||||
verifyTencentMock.mockResolvedValue(null)
|
||||
const wrapper = mountLogin()
|
||||
await flushPromises()
|
||||
await wrapper.get('#email').setValue('user@example.com')
|
||||
await wrapper.get('#password').setValue('secret-123')
|
||||
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(verifyTencentMock).toHaveBeenCalledOnce()
|
||||
expect(loginMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not open Tencent captcha when login form validation fails', async () => {
|
||||
const wrapper = mountLogin()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(verifyTencentMock).not.toHaveBeenCalled()
|
||||
expect(loginMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('starts OAuth through the Tencent gate before navigating', async () => {
|
||||
const wrapper = mountLogin()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('[data-testid="oauth-start"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(verifyTencentMock).toHaveBeenCalledOnce()
|
||||
expect(startOAuthLoginMock).toHaveBeenCalledWith(
|
||||
{ provider: 'github', params: { redirect: '/dashboard' } },
|
||||
{
|
||||
tencent_captcha_ticket: 'ticket-1',
|
||||
tencent_captcha_randstr: '@rand-1'
|
||||
}
|
||||
)
|
||||
expect(locationState.href).toBe('https://github.example/authorize')
|
||||
expect(captchaResetMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not start OAuth when Tencent captcha is closed', async () => {
|
||||
verifyTencentMock.mockResolvedValue(null)
|
||||
const wrapper = mountLogin()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('[data-testid="oauth-start"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(startOAuthLoginMock).not.toHaveBeenCalled()
|
||||
expect(locationState.href).toBe('http://localhost/login')
|
||||
})
|
||||
|
||||
it('passes a fresh Tencent proof to Passkey login', async () => {
|
||||
const wrapper = mountLogin()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('button.btn-secondary.w-full').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(verifyTencentMock).toHaveBeenCalledOnce()
|
||||
expect(loginWithPasskeyMock).toHaveBeenCalledWith({
|
||||
tencent_captcha_ticket: 'ticket-1',
|
||||
tencent_captcha_randstr: '@rand-1'
|
||||
})
|
||||
expect(captchaResetMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not invoke Passkey when Tencent captcha is closed', async () => {
|
||||
verifyTencentMock.mockResolvedValue(null)
|
||||
const wrapper = mountLogin()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('button.btn-secondary.w-full').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(loginWithPasskeyMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -617,6 +617,7 @@ describe('WechatCallbackView', () => {
|
||||
await wrapper.get('[data-testid="existing-account-submit"]').trigger('click')
|
||||
|
||||
expect(prepareOAuthBindAccessTokenCookieMock).toHaveBeenCalledTimes(1)
|
||||
expect(locationState.current.href).toContain('/api/v1/auth/oauth/wechat/bind/start?')
|
||||
expect(locationState.current.href).toContain('intent=bind_current_user')
|
||||
expect(locationState.current.href).toContain('redirect=%2Fusage')
|
||||
expect(locationState.current.href).toContain('mode=open')
|
||||
@@ -1052,7 +1053,7 @@ describe('WechatCallbackView', () => {
|
||||
|
||||
expect(exchangePendingOAuthCompletionMock).not.toHaveBeenCalled()
|
||||
expect(prepareOAuthBindAccessTokenCookieMock).toHaveBeenCalledTimes(1)
|
||||
expect(locationState.current.href).toContain('/api/v1/auth/oauth/wechat/start?')
|
||||
expect(locationState.current.href).toContain('/api/v1/auth/oauth/wechat/bind/start?')
|
||||
expect(locationState.current.href).toContain('mode=mp')
|
||||
expect(locationState.current.href).toContain('intent=bind_current_user')
|
||||
expect(locationState.current.href).toContain('redirect=%2Fprofile')
|
||||
|
||||
Reference in New Issue
Block a user