Merge pull request #5261 from lyen1688/feat/tencent-captcha-gate

新增腾讯天御验证码认证门禁
This commit is contained in:
Wesley Liddick
2026-08-04 16:39:55 +08:00
committed by GitHub
86 changed files with 3683 additions and 262 deletions
@@ -0,0 +1,45 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const post = vi.hoisted(() => vi.fn())
vi.mock('@/api/client', () => ({
apiClient: { post }
}))
import {
buildOAuthLoginStartURL,
startOAuthLogin,
type OAuthLoginStart
} from '@/api/auth'
describe('OAuth captcha start API', () => {
beforeEach(() => {
post.mockReset()
})
it('posts Tencent captcha proof and preserves OAuth query parameters', async () => {
const request: OAuthLoginStart = {
provider: 'github',
params: { redirect: '/dashboard', aff_code: 'AFF123' }
}
const proof = {
tencent_captcha_ticket: 'ticket',
tencent_captcha_randstr: '@rand'
}
post.mockResolvedValue({ data: { authorize_url: 'https://github.com/login/oauth/authorize' } })
await expect(startOAuthLogin(request, proof)).resolves.toEqual({
authorize_url: 'https://github.com/login/oauth/authorize'
})
expect(post).toHaveBeenCalledWith('/auth/oauth/github/start', proof, {
params: request.params
})
})
it('builds the legacy GET start URL when Tencent captcha is disabled', () => {
expect(buildOAuthLoginStartURL({
provider: 'wechat',
params: { mode: 'open', redirect: '/billing?plan=pro' }
})).toBe('/api/v1/auth/oauth/wechat/start?mode=open&redirect=%2Fbilling%3Fplan%3Dpro')
})
})
@@ -97,6 +97,7 @@ describe('passkey api', () => {
await passkeyAPI.login()
expect(post).toHaveBeenNthCalledWith(1, '/auth/passkey/login/begin')
const request = credentialGet.mock.calls[0][0] as CredentialRequestOptions
expect(Array.from(new Uint8Array(request.publicKey!.challenge))).toEqual([1, 2, 3])
expect(request.publicKey!.userVerification).toBe('required')
@@ -119,6 +120,38 @@ describe('passkey api', () => {
})
})
it('sends Tencent captcha proof only with the passkey begin request', async () => {
post
.mockResolvedValueOnce({
data: {
session_token: 'one-time-session',
options: {
publicKey: {
challenge: 'AQID',
rpId: 'sub2api.example.com',
userVerification: 'required'
}
}
}
})
.mockResolvedValueOnce({ data: { access_token: 'access', token_type: 'Bearer', user: { id: 1 } } })
credentialGet.mockResolvedValue(new FakePublicKeyCredential())
await passkeyAPI.login({
tencent_captcha_ticket: 'ticket-value',
tencent_captcha_randstr: '@rand-value'
})
expect(post).toHaveBeenNthCalledWith(1, '/auth/passkey/login/begin', {
tencent_captcha_ticket: 'ticket-value',
tencent_captcha_randstr: '@rand-value'
})
expect(post.mock.calls[1][1]).not.toEqual(expect.objectContaining({
tencent_captcha_ticket: expect.anything(),
tencent_captcha_randstr: expect.anything()
}))
})
it('sends the account password when beginning registration', async () => {
post
.mockResolvedValueOnce({
+10
View File
@@ -459,6 +459,11 @@ export interface SystemSettings {
turnstile_enabled: boolean;
turnstile_site_key: string;
turnstile_secret_key_configured: boolean;
tencent_captcha_enabled: boolean;
tencent_captcha_app_id: string;
tencent_captcha_app_secret_key_configured: boolean;
tencent_captcha_cloud_secret_id_configured: boolean;
tencent_captcha_cloud_secret_key_configured: boolean;
api_key_acl_trust_forwarded_ip: boolean;
forwarded_client_ip_headers: string[];
@@ -773,6 +778,11 @@ export interface UpdateSettingsRequest {
turnstile_enabled?: boolean;
turnstile_site_key?: string;
turnstile_secret_key?: string;
tencent_captcha_enabled?: boolean;
tencent_captcha_app_id?: string;
tencent_captcha_app_secret_key?: string;
tencent_captcha_cloud_secret_id?: string;
tencent_captcha_cloud_secret_key?: string;
api_key_acl_trust_forwarded_ip?: boolean;
forwarded_client_ip_headers?: string[];
linuxdo_connect_enabled?: boolean;
+40
View File
@@ -14,6 +14,7 @@ import type {
SendVerifyCodeRequest,
SendVerifyCodeResponse,
PublicSettings,
TencentCaptchaRequestProof,
TotpLoginResponse,
TotpLogin2FARequest
} from '@/types'
@@ -23,6 +24,43 @@ import type {
*/
export type LoginResponse = AuthResponse | TotpLoginResponse
export type OAuthLoginProvider =
| 'github'
| 'google'
| 'linuxdo'
| 'dingtalk'
| 'wechat'
| 'oidc'
export interface OAuthLoginStart {
provider: OAuthLoginProvider
params: Record<string, string>
}
export interface OAuthLoginStartResponse {
authorize_url: string
}
export function buildOAuthLoginStartURL(request: OAuthLoginStart): string {
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
const normalized = apiBase.replace(/\/$/, '')
const query = new URLSearchParams(request.params).toString()
const path = `${normalized}/auth/oauth/${request.provider}/start`
return query ? `${path}?${query}` : path
}
export async function startOAuthLogin(
request: OAuthLoginStart,
proof: TencentCaptchaRequestProof
): Promise<OAuthLoginStartResponse> {
const { data } = await apiClient.post<OAuthLoginStartResponse>(
`/auth/oauth/${request.provider}/start`,
proof,
{ params: request.params }
)
return data
}
/**
* Type guard to check if login response requires 2FA
*/
@@ -493,6 +531,8 @@ export async function validateInvitationCode(code: string): Promise<ValidateInvi
export interface ForgotPasswordRequest {
email: string
turnstile_token?: string
tencent_captcha_ticket?: string
tencent_captcha_randstr?: string
}
/**
+5 -5
View File
@@ -1,5 +1,5 @@
import { apiClient } from './client'
import type { AuthResponse } from '@/types'
import type { AuthResponse, TencentCaptchaRequestProof } from '@/types'
export interface PasskeyCredentialSummary {
id: number
@@ -104,11 +104,11 @@ function serializeAssertionCredential(credential: PublicKeyCredential): Record<s
}
}
async function login(): Promise<AuthResponse> {
async function login(proof?: TencentCaptchaRequestProof): Promise<AuthResponse> {
requirePasskeySupport()
const { data: begin } = await apiClient.post<CeremonyOptionsResponse>(
'/auth/passkey/login/begin'
)
const { data: begin } = proof
? await apiClient.post<CeremonyOptionsResponse>('/auth/passkey/login/begin', proof)
: await apiClient.post<CeremonyOptionsResponse>('/auth/passkey/login/begin')
const credential = await navigator.credentials.get({
publicKey: requestOptionsFromJSON(begin.options.publicKey)
})
@@ -0,0 +1,56 @@
<template>
<TurnstileWidget
v-if="turnstileEnabled && turnstileSiteKey"
ref="turnstileRef"
:site-key="turnstileSiteKey"
@verify="(token) => emit('verify', token, '')"
@expire="emit('expire')"
@error="emit('error')"
/>
<TencentCaptchaGate
v-else-if="tencentEnabled && tencentAppId"
ref="tencentRef"
:app-id="tencentAppId"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import TurnstileWidget from '@/components/TurnstileWidget.vue'
import TencentCaptchaGate from '@/components/TencentCaptchaGate.vue'
import type { TencentCaptchaProof } from '@/utils/tencentCaptcha'
const props = defineProps<{
siteKey?: string
turnstileEnabled: boolean
turnstileSiteKey: string
tencentEnabled: boolean
tencentAppId: string
}>()
const emit = defineEmits<{
verify: [tokenOrTicket: string, randstr: string]
expire: []
error: []
}>()
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
const tencentRef = ref<InstanceType<typeof TencentCaptchaGate> | null>(null)
function reset(): void {
turnstileRef.value?.reset()
tencentRef.value?.reset()
}
async function verifyTencent(): Promise<TencentCaptchaProof | null> {
if (!props.tencentEnabled || !props.tencentAppId) return null
try {
return (await tencentRef.value?.verify()) ?? null
} catch {
emit('error')
return null
}
}
defineExpose({ reset, verifyTencent })
</script>
@@ -0,0 +1,79 @@
<template>
<span v-if="false" />
</template>
<script setup lang="ts">
import { onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import {
loadTencentCaptcha,
type TencentCaptchaProof,
type TencentCaptchaResult
} from '@/utils/tencentCaptcha'
const { locale } = useI18n()
const props = defineProps<{ appId: string }>()
let instance: { show(): void; destroy(): void } | null = null
let pending: Promise<TencentCaptchaProof | null> | null = null
let cancelPending: (() => void) | null = null
function createVerificationPromise(): Promise<TencentCaptchaProof | null> {
return new Promise((resolve, reject) => {
let settled = false
const finish = (callback: () => void): void => {
if (settled) return
settled = true
if (cancelPending === cancel) cancelPending = null
instance?.destroy()
instance = null
callback()
}
const cancel = (): void => finish(() => resolve(null))
cancelPending = cancel
void loadTencentCaptcha()
.then((TencentCaptcha) => {
if (cancelPending !== cancel) return
const userLanguage = locale.value.toLowerCase().startsWith('zh') ? 'zh-cn' : 'en'
instance = new TencentCaptcha(props.appId, (result: TencentCaptchaResult) => {
if (result.ret === 2) {
finish(() => resolve(null))
return
}
const ticket = result.ticket?.trim() || ''
const randstr = result.randstr?.trim() || ''
if (!ticket || !randstr || ticket.startsWith('trerror_') || result.errorCode !== undefined) {
finish(() => reject(new Error('Tencent Captcha verification failed')))
return
}
finish(() => resolve({ ticket, randstr }))
}, { userLanguage })
instance.show()
})
.catch((error: unknown) => finish(() => reject(error)))
})
}
function verify(): Promise<TencentCaptchaProof | null> {
if (pending) return pending
pending = createVerificationPromise().finally(() => {
pending = null
})
return pending
}
function reset(): void {
instance?.destroy()
instance = null
cancelPending?.()
cancelPending = null
}
onBeforeUnmount(reset)
defineExpose({ verify, reset })
</script>
@@ -0,0 +1,131 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TencentCaptchaGate from '@/components/TencentCaptchaGate.vue'
import { resetTencentCaptchaLoaderForTest } from '@/utils/tencentCaptcha'
const locale = { value: 'zh' }
vi.mock('vue-i18n', () => ({
useI18n: () => ({ locale })
}))
type CaptchaResult = {
ret: number
ticket?: string | null
randstr?: string | null
errorCode?: number
}
describe('TencentCaptchaGate', () => {
beforeEach(() => {
locale.value = 'zh'
delete window.TencentCaptcha
document.head.querySelectorAll('script[src*="TJCaptcha.js"]').forEach((node) => node.remove())
resetTencentCaptchaLoaderForTest()
})
it('does not render a visible verification button', () => {
const wrapper = mount(TencentCaptchaGate, { props: { appId: '123456789' } })
expect(wrapper.find('button').exists()).toBe(false)
})
it('resolves proof after Tencent SDK success', async () => {
let callback: ((result: CaptchaResult) => void) | undefined
window.TencentCaptcha = class {
constructor(_appId: string, resultCallback: (result: CaptchaResult) => void) {
callback = resultCallback
}
show = vi.fn()
destroy = vi.fn()
}
const wrapper = mount(TencentCaptchaGate, { props: { appId: '123456789' } })
const verification = wrapper.vm.verify()
await flushPromises()
callback?.({ ret: 0, ticket: 'ticket-value', randstr: 'rand-value' })
await expect(verification).resolves.toEqual({ ticket: 'ticket-value', randstr: 'rand-value' })
})
it('resolves null when the user closes the popup', async () => {
let callback: ((result: CaptchaResult) => void) | undefined
window.TencentCaptcha = class {
constructor(_appId: string, resultCallback: (result: CaptchaResult) => void) {
callback = resultCallback
}
show = vi.fn()
destroy = vi.fn()
}
const wrapper = mount(TencentCaptchaGate, { props: { appId: '123456789' } })
const verification = wrapper.vm.verify()
await flushPromises()
callback?.({ ret: 2, ticket: null })
await expect(verification).resolves.toBeNull()
})
it('rejects SDK load failures and disaster-recovery tickets', async () => {
const failedLoad = mount(TencentCaptchaGate, { props: { appId: '123456789' } })
const loadVerification = failedLoad.vm.verify()
const script = document.head.querySelector<HTMLScriptElement>('script[src*="TJCaptcha.js"]')
expect(script).not.toBeNull()
script?.dispatchEvent(new Event('error'))
await expect(loadVerification).rejects.toThrow('Failed to load Tencent Captcha SDK')
let callback: ((result: CaptchaResult) => void) | undefined
window.TencentCaptcha = class {
constructor(_appId: string, resultCallback: (result: CaptchaResult) => void) {
callback = resultCallback
}
show = vi.fn()
destroy = vi.fn()
}
const failedResult = mount(TencentCaptchaGate, { props: { appId: '123456789' } })
const resultVerification = failedResult.vm.verify()
await flushPromises()
callback?.({ ret: 0, ticket: 'trerror_1001_123456789', randstr: '@fallback', errorCode: 1001 })
await expect(resultVerification).rejects.toThrow('Tencent Captcha verification failed')
})
it('reuses one pending promise for concurrent verify calls', async () => {
const show = vi.fn()
let callback: ((result: CaptchaResult) => void) | undefined
window.TencentCaptcha = class {
constructor(_appId: string, resultCallback: (result: CaptchaResult) => void) {
callback = resultCallback
}
show = show
destroy = vi.fn()
}
const wrapper = mount(TencentCaptchaGate, { props: { appId: '123456789' } })
const first = wrapper.vm.verify()
const second = wrapper.vm.verify()
await flushPromises()
callback?.({ ret: 0, ticket: 'ticket-value', randstr: 'rand-value' })
await expect(first).resolves.toEqual({ ticket: 'ticket-value', randstr: 'rand-value' })
await expect(second).resolves.toEqual({ ticket: 'ticket-value', randstr: 'rand-value' })
expect(show).toHaveBeenCalledOnce()
})
it('settles a pending verification when reset', async () => {
const destroy = vi.fn()
window.TencentCaptcha = class {
constructor(_appId: string, _callback: (result: CaptchaResult) => void) {}
show = vi.fn()
destroy = destroy
}
const wrapper = mount(TencentCaptchaGate, { props: { appId: '123456789' } })
const verification = wrapper.vm.verify()
await flushPromises()
wrapper.vm.reset()
await expect(verification).resolves.toBeNull()
expect(destroy).toHaveBeenCalledOnce()
})
})
@@ -37,6 +37,7 @@
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import type { OAuthLoginStart } from '@/api/auth'
import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate'
const props = withDefaults(defineProps<{
@@ -46,6 +47,9 @@ const props = withDefaults(defineProps<{
}>(), {
showDivider: true
})
const emit = defineEmits<{
start: [request: OAuthLoginStart]
}>()
const route = useRoute()
const { t } = useI18n()
@@ -53,9 +57,6 @@ const { t } = useI18n()
function startLogin(): void {
const redirectTo = (route.query.redirect as string) || '/dashboard'
storeOAuthAffiliateCode(resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code))
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
const normalized = apiBase.replace(/\/$/, '')
const startURL = `${normalized}/auth/oauth/dingtalk/start?redirect=${encodeURIComponent(redirectTo)}`
window.location.href = startURL
emit('start', { provider: 'dingtalk', params: { redirect: redirectTo } })
}
</script>
@@ -31,6 +31,7 @@ import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import GitHubMark from './GitHubMark.vue'
import GoogleMark from './GoogleMark.vue'
import type { OAuthLoginStart } from '@/api/auth'
import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate'
type EmailOAuthProvider = 'github' | 'google'
@@ -45,6 +46,9 @@ const props = withDefaults(defineProps<{
}>(), {
showDivider: true
})
const emit = defineEmits<{
start: [request: OAuthLoginStart]
}>()
const route = useRoute()
const { t } = useI18n()
@@ -75,13 +79,10 @@ function startLogin(provider: EmailOAuthProvider): void {
const affiliateCode = resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code)
storeOAuthAffiliateCode(affiliateCode)
window.sessionStorage.setItem(EMAIL_OAUTH_PENDING_PROVIDER_KEY, provider)
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
const normalized = apiBase.replace(/\/$/, '')
const params = new URLSearchParams({ redirect: redirectTo })
const params: Record<string, string> = { redirect: redirectTo }
if (affiliateCode) {
params.set('aff_code', affiliateCode)
params.aff_code = affiliateCode
}
const startURL = `${normalized}/auth/oauth/${provider}/start?${params.toString()}`
window.location.href = startURL
emit('start', { provider, params })
}
</script>
@@ -42,6 +42,7 @@
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import type { OAuthLoginStart } from '@/api/auth'
import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate'
const props = withDefaults(defineProps<{
@@ -51,6 +52,9 @@ const props = withDefaults(defineProps<{
}>(), {
showDivider: true
})
const emit = defineEmits<{
start: [request: OAuthLoginStart]
}>()
const route = useRoute()
const { t } = useI18n()
@@ -58,9 +62,6 @@ const { t } = useI18n()
function startLogin(): void {
const redirectTo = (route.query.redirect as string) || '/dashboard'
storeOAuthAffiliateCode(resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code))
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
const normalized = apiBase.replace(/\/$/, '')
const startURL = `${normalized}/auth/oauth/linuxdo/start?redirect=${encodeURIComponent(redirectTo)}`
window.location.href = startURL
emit('start', { provider: 'linuxdo', params: { redirect: redirectTo } })
}
</script>
@@ -23,6 +23,7 @@
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import type { OAuthLoginStart } from '@/api/auth'
import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate'
const props = withDefaults(defineProps<{
@@ -34,6 +35,9 @@ const props = withDefaults(defineProps<{
providerName: 'OIDC',
showDivider: true
})
const emit = defineEmits<{
start: [request: OAuthLoginStart]
}>()
const route = useRoute()
const { t } = useI18n()
@@ -48,9 +52,6 @@ const providerInitial = computed(() => normalizedProviderName.value.charAt(0).to
function startLogin(): void {
const redirectTo = (route.query.redirect as string) || '/dashboard'
storeOAuthAffiliateCode(resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code))
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
const normalized = apiBase.replace(/\/$/, '')
const startURL = `${normalized}/auth/oauth/oidc/start?redirect=${encodeURIComponent(redirectTo)}`
window.location.href = startURL
emit('start', { provider: 'oidc', params: { redirect: redirectTo } })
}
</script>
@@ -16,10 +16,14 @@
:placeholder="t('auth.passwordPlaceholder')"
:disabled="isSubmitting"
/>
<div v-if="emailVerifyEnabled && turnstileEnabled && turnstileSiteKey" class="space-y-2">
<div v-if="captchaEnabled" class="space-y-2">
<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"
@@ -88,9 +92,9 @@
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import TurnstileWidget from '@/components/TurnstileWidget.vue'
import TurnstileWidget from '@/components/CaptchaChallenge.vue'
import { getPublicSettings, sendPendingOAuthVerifyCode } from '@/api/auth'
import { useAppStore } from '@/stores'
@@ -98,6 +102,9 @@ export type PendingOAuthCreateAccountPayload = {
email: string
password: string
verifyCode: string
turnstileToken?: string
tencentCaptchaTicket?: string
tencentCaptchaRandstr?: string
invitationCode?: string
}
@@ -128,8 +135,16 @@ const invitationCodeEnabled = ref(false)
const emailVerifyEnabled = ref(true)
const turnstileEnabled = ref(false)
const turnstileSiteKey = ref('')
const tencentCaptchaEnabled = ref(false)
const tencentCaptchaAppId = ref('')
const turnstileToken = ref('')
const tencentCaptchaRandstr = ref('')
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
const captchaEnabled = computed(
() =>
(turnstileEnabled.value && Boolean(turnstileSiteKey.value)) ||
(tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value))
)
let countdownTimer: ReturnType<typeof setInterval> | null = null
@@ -152,6 +167,9 @@ watch(
value => {
if (value) {
appStore.showError(value)
if (captchaEnabled.value) {
resetTurnstile()
}
}
}
)
@@ -189,24 +207,39 @@ function getRequestErrorMessage(error: unknown, fallback: string): string {
function resetTurnstile() {
turnstileToken.value = ''
tencentCaptchaRandstr.value = ''
turnstileRef.value?.reset()
}
function onTurnstileVerify(token: string) {
function onTurnstileVerify(token: string, randstr = '') {
turnstileToken.value = token
tencentCaptchaRandstr.value = randstr
sendCodeError.value = ''
}
function onTurnstileExpire() {
turnstileToken.value = ''
tencentCaptchaRandstr.value = ''
sendCodeError.value = t('auth.turnstileExpired')
}
function onTurnstileError() {
turnstileToken.value = ''
tencentCaptchaRandstr.value = ''
sendCodeError.value = t('auth.turnstileFailed')
}
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 handleSendCode() {
const trimmedEmail = email.value.trim()
if (!trimmedEmail) {
@@ -218,6 +251,10 @@ async function handleSendCode() {
return
}
if (!(await acquireTencentProof())) {
return
}
isSendingCode.value = true
sendCodeError.value = ''
sendCodeSuccess.value = false
@@ -225,32 +262,49 @@ async function handleSendCode() {
try {
const response = await sendPendingOAuthVerifyCode({
email: trimmedEmail,
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
})
sendCodeSuccess.value = true
startCountdown(response.countdown)
if (turnstileEnabled.value) {
resetTurnstile()
}
} catch (error: unknown) {
sendCodeError.value = getRequestErrorMessage(error, t('auth.sendCodeFailed'))
} finally {
if (captchaEnabled.value) {
resetTurnstile()
}
isSendingCode.value = false
}
}
function handleSubmit() {
async function handleSubmit() {
const trimmedEmail = email.value.trim()
if (!trimmedEmail || password.value.length < 6) {
return
}
if (!(await acquireTencentProof())) {
return
}
emit('submit', {
email: trimmedEmail,
password: password.value,
verifyCode: emailVerifyEnabled.value ? verifyCode.value.trim() : '',
...(turnstileEnabled.value && turnstileToken.value ? { turnstileToken: turnstileToken.value } : {}),
...(tencentCaptchaEnabled.value && turnstileToken.value
? {
tencentCaptchaTicket: turnstileToken.value,
tencentCaptchaRandstr: tencentCaptchaRandstr.value
}
: {}),
invitationCode: invitationCode.value.trim() || undefined
})
if (tencentCaptchaEnabled.value) {
resetTurnstile()
}
}
function emitSwitchToBind() {
@@ -264,11 +318,15 @@ onMounted(async () => {
emailVerifyEnabled.value = settings.email_verify_enabled !== false
turnstileEnabled.value = settings.turnstile_enabled === true
turnstileSiteKey.value = settings.turnstile_site_key || ''
tencentCaptchaEnabled.value = settings.tencent_captcha_enabled === true
tencentCaptchaAppId.value = settings.tencent_captcha_app_id || ''
} catch {
invitationCodeEnabled.value = false
emailVerifyEnabled.value = true
turnstileEnabled.value = false
turnstileSiteKey.value = ''
tencentCaptchaEnabled.value = false
tencentCaptchaAppId.value = ''
}
})
@@ -31,7 +31,7 @@
import { computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { resolveWeChatOAuthStart } from '@/api/auth'
import { resolveWeChatOAuthStart, type OAuthLoginStart } from '@/api/auth'
import { useAppStore } from '@/stores'
import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate'
@@ -42,6 +42,9 @@ const props = withDefaults(defineProps<{
}>(), {
showDivider: true,
})
const emit = defineEmits<{
start: [request: OAuthLoginStart]
}>()
const appStore = useAppStore()
const route = useRoute()
@@ -87,10 +90,10 @@ function startLogin(): void {
}
const redirectTo = (route.query.redirect as string) || '/dashboard'
storeOAuthAffiliateCode(resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code))
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
const normalized = apiBase.replace(/\/$/, '')
const mode = resolvedStart.value.mode
const startURL = `${normalized}/auth/oauth/wechat/start?mode=${mode}&redirect=${encodeURIComponent(redirectTo)}`
window.location.href = startURL
emit('start', {
provider: 'wechat',
params: { mode, redirect: redirectTo }
})
}
</script>
@@ -6,10 +6,6 @@ const routeState = vi.hoisted(() => ({
query: {} as Record<string, unknown>,
}))
const locationState = vi.hoisted(() => ({
current: { href: 'http://localhost/register?aff=AFF123' } as { href: string },
}))
vi.mock('vue-router', () => ({
useRoute: () => routeState,
}))
@@ -28,16 +24,11 @@ vi.mock('vue-i18n', () => ({
describe('EmailOAuthButtons', () => {
beforeEach(() => {
routeState.query = { redirect: '/billing?plan=pro', aff: 'AFF123' }
locationState.current = { href: 'http://localhost/register?aff=AFF123' }
Object.defineProperty(window, 'location', {
configurable: true,
value: locationState.current,
})
window.localStorage.clear()
window.sessionStorage.clear()
})
it('passes the affiliate code to the email oauth start URL', async () => {
it('emits the GitHub OAuth request with redirect and affiliate parameters', async () => {
const wrapper = mount(EmailOAuthButtons, {
props: {
githubEnabled: true,
@@ -53,13 +44,40 @@ describe('EmailOAuthButtons', () => {
await wrapper.get('button').trigger('click')
expect(locationState.current.href).toBe(
'/api/v1/auth/oauth/github/start?redirect=%2Fbilling%3Fplan%3Dpro&aff_code=AFF123'
)
expect(wrapper.emitted('start')).toEqual([[
{
provider: 'github',
params: { redirect: '/billing?plan=pro', aff_code: 'AFF123' }
}
]])
expect(window.sessionStorage.getItem('oauth_aff_code')).toBe('AFF123')
expect(window.sessionStorage.getItem('email_oauth_pending_provider')).toBe('github')
})
it('emits the Google provider without navigating directly', async () => {
const originalHref = window.location.href
const wrapper = mount(EmailOAuthButtons, {
props: {
githubEnabled: false,
googleEnabled: true,
},
global: {
stubs: {
GitHubMark: true,
GoogleMark: true,
},
},
})
await wrapper.get('button').trigger('click')
expect(wrapper.emitted('start')?.[0]?.[0]).toEqual({
provider: 'google',
params: { redirect: '/billing?plan=pro', aff_code: 'AFF123' }
})
expect(window.location.href).toBe(originalHref)
})
it('uses a full-width descriptive button when only GitHub is enabled', () => {
const wrapper = mount(EmailOAuthButtons, {
props: {
@@ -0,0 +1,44 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
import DingTalkOAuthSection from '@/components/auth/DingTalkOAuthSection.vue'
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
const routeState = vi.hoisted(() => ({
query: {} as Record<string, unknown>
}))
vi.mock('vue-router', () => ({
useRoute: () => routeState
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
describe('OAuth login sections', () => {
beforeEach(() => {
routeState.query = { redirect: '/billing?plan=pro', aff: 'AFF123' }
window.sessionStorage.clear()
})
it.each([
['linuxdo', LinuxDoOAuthSection],
['dingtalk', DingTalkOAuthSection],
['oidc', OidcOAuthSection]
] as const)('emits a %s start request from the original button', async (provider, component) => {
const originalHref = window.location.href
const wrapper = mount(component, { props: { affCode: 'AFF456' } })
await wrapper.get('button').trigger('click')
expect(wrapper.emitted('start')?.[0]?.[0]).toEqual({
provider,
params: { redirect: '/billing?plan=pro' }
})
expect(window.sessionStorage.getItem('oauth_aff_code')).toBe('AFF456')
expect(window.location.href).toBe(originalHref)
})
})
@@ -1,3 +1,4 @@
import { defineComponent, h } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
@@ -7,6 +8,8 @@ const sendVerifyCode = vi.fn()
const sendPendingOAuthVerifyCode = vi.fn()
const getPublicSettings = vi.fn()
const showError = vi.fn()
const turnstileReset = vi.fn()
const verifyTencent = vi.fn()
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
@@ -40,12 +43,69 @@ describe('PendingOAuthCreateAccountForm', () => {
sendPendingOAuthVerifyCode.mockReset()
getPublicSettings.mockReset()
showError.mockReset()
turnstileReset.mockReset()
verifyTencent.mockReset()
getPublicSettings.mockResolvedValue({
turnstile_enabled: false,
turnstile_site_key: ''
})
})
it('acquires separate proofs for pending OAuth send-code and create-account', async () => {
getPublicSettings.mockResolvedValue({
email_verify_enabled: true,
turnstile_enabled: false,
turnstile_site_key: '',
tencent_captcha_enabled: true,
tencent_captcha_app_id: 'tencent-app-id'
})
sendPendingOAuthVerifyCode.mockResolvedValue({ countdown: 0 })
verifyTencent
.mockResolvedValueOnce({ ticket: 'ticket-1', randstr: '@rand-1' })
.mockResolvedValueOnce({ ticket: 'ticket-2', randstr: '@rand-2' })
const CaptchaChallengeStub = defineComponent({
setup(_, { expose }) {
expose({ verifyTencent, reset: turnstileReset })
return () => h('div')
}
})
const wrapper = mount(PendingOAuthCreateAccountForm, {
props: {
testIdPrefix: 'oidc',
initialEmail: 'user@example.com',
isSubmitting: false
},
global: {
stubs: { TurnstileWidget: CaptchaChallengeStub }
}
})
await flushPromises()
await wrapper.get('[data-testid="oidc-create-account-password"]').setValue('secret-123')
await wrapper.get('[data-testid="oidc-create-account-verify-code"]').setValue('246810')
await wrapper.get('[data-testid="oidc-create-account-send-code"]').trigger('click')
await flushPromises()
await wrapper.get('[data-testid="oidc-create-account-submit"]').trigger('click')
await flushPromises()
expect(verifyTencent).toHaveBeenCalledTimes(2)
expect(sendPendingOAuthVerifyCode).toHaveBeenCalledWith({
email: 'user@example.com',
tencent_captcha_ticket: 'ticket-1',
tencent_captcha_randstr: '@rand-1'
})
expect(wrapper.emitted('submit')).toEqual([
[
expect.objectContaining({
tencentCaptchaTicket: 'ticket-2',
tencentCaptchaRandstr: '@rand-2'
})
]
])
expect(turnstileReset).toHaveBeenCalledTimes(2)
})
it('emits trimmed email, password, and verify code on submit', async () => {
const wrapper = mount(PendingOAuthCreateAccountForm, {
props: {
@@ -195,6 +255,38 @@ describe('PendingOAuthCreateAccountForm', () => {
expect(wrapper.text()).not.toContain('send failed')
})
it('consumes the captcha proof when sending a verify code fails', async () => {
getPublicSettings.mockResolvedValue({
turnstile_enabled: true,
turnstile_site_key: 'site-key'
})
sendPendingOAuthVerifyCode.mockRejectedValue(new Error('send failed'))
const wrapper = mount(PendingOAuthCreateAccountForm, {
props: {
testIdPrefix: 'oidc',
initialEmail: 'user@example.com',
isSubmitting: false
},
global: {
stubs: {
TurnstileWidget: {
template: '<button data-testid="turnstile-verify" @click="$emit(\'verify\', \'proof-token\')">verify</button>',
methods: { reset: turnstileReset }
}
}
}
})
await flushPromises()
await wrapper.get('[data-testid="turnstile-verify"]').trigger('click')
await wrapper.get('[data-testid="oidc-create-account-send-code"]').trigger('click')
await flushPromises()
expect(turnstileReset).toHaveBeenCalledOnce()
expect(wrapper.get('[data-testid="oidc-create-account-send-code"]').attributes('disabled')).toBeDefined()
})
it('requires a turnstile token before sending a verify code when turnstile is enabled', async () => {
getPublicSettings.mockResolvedValue({
turnstile_enabled: true,
@@ -215,7 +307,8 @@ describe('PendingOAuthCreateAccountForm', () => {
global: {
stubs: {
TurnstileWidget: {
template: '<button data-testid="turnstile-verify" @click="$emit(\'verify\', \'turnstile-token\')">verify</button>'
template: '<button data-testid="turnstile-verify" @click="$emit(\'verify\', \'turnstile-token\')">verify</button>',
methods: { reset: vi.fn() }
}
}
}
@@ -9,10 +9,6 @@ const routeState = vi.hoisted(() => ({
query: {} as Record<string, unknown>,
}))
const locationState = vi.hoisted(() => ({
current: { href: 'http://localhost/login' } as { href: string },
}))
let pinia: ReturnType<typeof createPinia>
vi.mock('vue-router', () => ({
@@ -105,11 +101,6 @@ describe('WechatOAuthSection', () => {
pinia = createPinia()
setActivePinia(pinia)
routeState.query = { redirect: '/billing?plan=pro' }
locationState.current = { href: 'http://localhost/login' }
Object.defineProperty(window, 'location', {
configurable: true,
value: locationState.current,
})
Object.defineProperty(window.navigator, 'userAgent', {
configurable: true,
value: 'Mozilla/5.0',
@@ -135,9 +126,10 @@ describe('WechatOAuthSection', () => {
await wrapper.get('button').trigger('click')
expect(locationState.current.href).toContain(
'/api/v1/auth/oauth/wechat/start?mode=open&redirect=%2Fbilling%3Fplan%3Dpro'
)
expect(wrapper.emitted('start')?.[0]?.[0]).toEqual({
provider: 'wechat',
params: { mode: 'open', redirect: '/billing?plan=pro' }
})
})
it('uses mp mode inside the WeChat browser when mp mode is configured', async () => {
@@ -157,9 +149,10 @@ describe('WechatOAuthSection', () => {
await wrapper.get('button').trigger('click')
expect(locationState.current.href).toContain(
'/api/v1/auth/oauth/wechat/start?mode=mp&redirect=%2Fbilling%3Fplan%3Dpro'
)
expect(wrapper.emitted('start')?.[0]?.[0]).toEqual({
provider: 'wechat',
params: { mode: 'mp', redirect: '/billing?plan=pro' }
})
})
it('disables the button outside the WeChat browser when only mp mode is configured', async () => {
@@ -178,7 +171,7 @@ describe('WechatOAuthSection', () => {
await wrapper.get('button').trigger('click')
expect(locationState.current.href).toBe('http://localhost/login')
expect(wrapper.emitted('start')).toBeUndefined()
})
it('disables the button inside the WeChat browser when only open mode is configured', async () => {
@@ -201,7 +194,7 @@ describe('WechatOAuthSection', () => {
await wrapper.get('button').trigger('click')
expect(locationState.current.href).toBe('http://localhost/login')
expect(wrapper.emitted('start')).toBeUndefined()
})
it('uses the legacy overall enabled flag when per-mode settings are not present', async () => {
@@ -216,9 +209,10 @@ describe('WechatOAuthSection', () => {
await wrapper.get('button').trigger('click')
expect(locationState.current.href).toContain(
'/api/v1/auth/oauth/wechat/start?mode=open&redirect=%2Fbilling%3Fplan%3Dpro'
)
expect(wrapper.emitted('start')?.[0]?.[0]).toEqual({
provider: 'wechat',
params: { mode: 'open', redirect: '/billing?plan=pro' }
})
})
it('shows the localized not-configured hint when WeChat OAuth is unavailable', async () => {
@@ -183,6 +183,29 @@ export default {
secretKeyHint: 'Server-side verification key (keep this secret)',
secretKeyConfiguredHint: 'Secret key configured. Leave empty to keep the current value.'
},
tencentCaptcha: {
title: 'Tencent Captcha',
description: 'Slider captcha protection for login, registration, and third-party account creation',
enable: 'Enable Tencent Captcha',
enableHint: 'Use Tencent slider captcha in every existing Turnstile flow',
keepExisting: 'Leave empty to keep current value',
configured: 'Configured. Leave empty to keep it.',
required: 'Required before enabling.',
mutualExclusion: 'Tencent Captcha and Cloudflare Turnstile are mutually exclusive. Enabling one disables the other.',
appCredentialsTitle: 'Captcha application credentials',
appCredentialsHint: 'Get CaptchaAppId and AppSecretKey from Verification Management in the Captcha console.',
cloudCredentialsTitle: 'Cloud API credentials',
cloudCredentialsHint: 'SecretId and SecretKey authorize server-side DescribeCaptchaResult requests.',
appId: 'CaptchaAppId',
appSecretKey: 'AppSecretKey',
cloudSecretId: 'Tencent Cloud SecretId',
cloudSecretKey: 'Tencent Cloud SecretKey',
camPermissionHint: 'Create a CAM sub-user with QcloudCaptchaFullAccess instead of using permanent root-account credentials.',
aidEncryptedHint: 'aidEncrypted is not supported yet. Keep CaptchaAppId mandatory verification disabled in the Captcha console.',
openCaptchaConsole: 'Open Captcha console',
createCloudKeys: 'Create SecretId / SecretKey',
openWebDocs: 'View Web integration guide'
},
apiKeyAcl: {
title: 'API Key IP Access Control',
description:
+2
View File
@@ -243,6 +243,8 @@ export default {
reloginRequired: 'Session expired. Please log in again.',
turnstileExpired: 'Verification expired, please try again',
turnstileFailed: 'Verification failed, please try again',
captchaVerified: 'Verification completed',
captchaLoading: 'Loading verification…',
completeVerification: 'Please complete the verification',
verifyYourEmail: 'Verify Your Email',
sessionExpired: 'Session expired',
@@ -183,6 +183,29 @@ export default {
secretKeyHint: '服务端验证密钥(请保密)',
secretKeyConfiguredHint: '密钥已配置,留空以保留当前值。'
},
tencentCaptcha: {
title: '腾讯天御验证码',
description: '为登录、注册及第三方登录创建账号流程提供滑动验证码保护',
enable: '启用腾讯天御验证码',
enableHint: '启用后将在所有原 Turnstile 场景使用腾讯滑动验证码',
keepExisting: '留空以保留当前值',
configured: '已配置,留空不会覆盖。',
required: '启用前必须填写此项。',
mutualExclusion: '腾讯天御验证码与 Cloudflare Turnstile 互斥,开启其中一个会自动关闭另一个。',
appCredentialsTitle: '验证码应用密钥',
appCredentialsHint: 'CaptchaAppId 与 AppSecretKey 来自验证码控制台的验证管理页面。',
cloudCredentialsTitle: '云 API 调用密钥',
cloudCredentialsHint: 'SecretId 与 SecretKey 用于服务端调用 DescribeCaptchaResult 接口。',
appId: 'CaptchaAppId',
appSecretKey: 'AppSecretKey',
cloudSecretId: '腾讯云 SecretId',
cloudSecretKey: '腾讯云 SecretKey',
camPermissionHint: '推荐创建 CAM 子用户并授予 QcloudCaptchaFullAccess,避免使用主账号永久密钥。',
aidEncryptedHint: '当前版本暂不支持 aidEncrypted,请先不要在验证码控制台开启 CaptchaAppId 强制校验。',
openCaptchaConsole: '打开验证码控制台',
createCloudKeys: '创建 SecretId / SecretKey',
openWebDocs: '查看 Web 接入文档'
},
apiKeyAcl: {
title: 'API Key IP 访问控制',
description: '控制 API Key 白/黑名单、操作审计日志与会话 IP/UA 绑定使用哪个客户端 IP 判断',
+2
View File
@@ -242,6 +242,8 @@ export default {
reloginRequired: '会话已过期,请重新登录。',
turnstileExpired: '验证已过期,请重试',
turnstileFailed: '验证失败,请重试',
captchaVerified: '验证已完成',
captchaLoading: '正在加载验证码…',
completeVerification: '请完成验证',
verifyYourEmail: '验证您的邮箱',
sessionExpired: '会话已过期',
+9 -3
View File
@@ -6,7 +6,13 @@
import { defineStore } from 'pinia'
import { ref, computed, readonly } from 'vue'
import { authAPI, isTotp2FARequired, passkeyAPI, type LoginResponse } from '@/api'
import type { User, LoginRequest, RegisterRequest, AuthResponse } from '@/types'
import type {
User,
LoginRequest,
RegisterRequest,
AuthResponse,
TencentCaptchaRequestProof
} from '@/types'
const AUTH_TOKEN_KEY = 'auth_token'
const AUTH_USER_KEY = 'auth_user'
@@ -275,9 +281,9 @@ export const useAuthStore = defineStore('auth', () => {
}
}
async function loginWithPasskey(): Promise<User> {
async function loginWithPasskey(proof?: TencentCaptchaRequestProof): Promise<User> {
try {
const response = await passkeyAPI.login()
const response = await passkeyAPI.login(proof)
setAuthFromResponse(response)
return user.value!
} catch (error) {
+13
View File
@@ -115,6 +115,13 @@ export interface LoginRequest {
email: string
password: string
turnstile_token?: string
tencent_captcha_ticket?: string
tencent_captcha_randstr?: string
}
export interface TencentCaptchaRequestProof {
tencent_captcha_ticket: string
tencent_captcha_randstr: string
}
export interface RegisterRequest {
@@ -122,6 +129,8 @@ export interface RegisterRequest {
password: string
verify_code?: string
turnstile_token?: string
tencent_captcha_ticket?: string
tencent_captcha_randstr?: string
promo_code?: string
invitation_code?: string
aff_code?: string
@@ -156,6 +165,8 @@ export interface AffiliateTransferResponse {
export interface SendVerifyCodeRequest {
email: string
turnstile_token?: string
tencent_captcha_ticket?: string
tencent_captcha_randstr?: string
pending_auth_token?: string
pending_oauth_token?: string
}
@@ -201,6 +212,8 @@ export interface PublicSettings {
login_agreement_revision?: string
login_agreement_documents?: LoginAgreementDocument[]
turnstile_enabled: boolean
tencent_captcha_enabled?: boolean
tencent_captcha_app_id?: string
passkey_enabled?: boolean
turnstile_site_key: string
site_name: string
+62
View File
@@ -0,0 +1,62 @@
export interface TencentCaptchaProof {
ticket: string
randstr: string
}
export interface TencentCaptchaResult {
ret: number
ticket?: string | null
randstr?: string | null
errorCode?: number
errorMessage?: string
}
interface TencentCaptchaInstance {
show(): void
destroy(): void
}
type TencentCaptchaConstructor = new (
appId: string,
callback: (result: TencentCaptchaResult) => void,
options?: Record<string, unknown>
) => TencentCaptchaInstance
declare global {
interface Window {
TencentCaptcha?: TencentCaptchaConstructor
}
}
const SCRIPT_SRC = 'https://turing.captcha.qcloud.com/TJCaptcha.js'
let scriptPromise: Promise<TencentCaptchaConstructor> | null = null
export function loadTencentCaptcha(): Promise<TencentCaptchaConstructor> {
if (window.TencentCaptcha) return Promise.resolve(window.TencentCaptcha)
if (scriptPromise) return scriptPromise
scriptPromise = new Promise((resolve, reject) => {
const script = document.createElement('script')
script.src = SCRIPT_SRC
script.async = true
script.onload = () => {
if (window.TencentCaptcha) {
resolve(window.TencentCaptcha)
return
}
scriptPromise = null
reject(new Error('Tencent Captcha SDK is unavailable'))
}
script.onerror = () => {
scriptPromise = null
reject(new Error('Failed to load Tencent Captcha SDK'))
}
document.head.appendChild(script)
})
return scriptPromise
}
export function resetTencentCaptchaLoaderForTest(): void {
scriptPromise = null
}
+180 -1
View File
@@ -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
@@ -8838,6 +8987,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;
@@ -8967,6 +9119,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 登录
@@ -9130,6 +9290,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"
@@ -10095,6 +10263,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 = "";
@@ -10464,6 +10635,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
}
)
+172 -19
View File
@@ -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
}
}
+48 -11
View File
@@ -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())
+114 -14
View File
@@ -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())
+90 -11
View File
@@ -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')