From 635a27189fb7ebdfb0e8a905969a971caa4c3ee5 Mon Sep 17 00:00:00 2001 From: shaw Date: Tue, 4 Aug 2026 20:29:53 +0800 Subject: [PATCH] fix(auth-ui): gate pending OAuth account creation on a captcha proof MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #5261 added a captcha check to POST /auth/oauth/pending/create-account, but the shared create-account form only gates its send-code button on the Turnstile token — the submit button's disabled condition never included it. Turnstile tokens are single-use, so handleSendCode resets the widget in its finally block and clears the token. Submitting inside the window before the widget re-solves omits turnstile_token from the payload, and the backend answers ErrTurnstileVerificationFailed (turnstile_service.go:65). With an interaction-required challenge the widget does not re-solve on its own, so the failure persists until the user notices and verifies again — while the button stays enabled and says nothing. Gate the submit button on the token, mirroring the send-code button and EmailVerifyView, which already gates its pending-OAuth submit the same way. handleSubmit repeats the check because implicit form submission (Enter in a text input) bypasses the button's disabled state. The Tencent path is unaffected: handleSubmit already acquires a fresh proof per submit, and turnstile_enabled is false in that configuration. Verified with the component spec (11 passed) and vue-tsc --noEmit (clean). --- .../auth/PendingOAuthCreateAccountForm.vue | 10 +- .../PendingOAuthCreateAccountForm.spec.ts | 95 +++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/auth/PendingOAuthCreateAccountForm.vue b/frontend/src/components/auth/PendingOAuthCreateAccountForm.vue index 0403843eb4..ff8721dbdc 100644 --- a/frontend/src/components/auth/PendingOAuthCreateAccountForm.vue +++ b/frontend/src/components/auth/PendingOAuthCreateAccountForm.vue @@ -75,7 +75,7 @@ :data-testid="`${testIdPrefix}-create-account-submit`" type="button" class="btn btn-primary w-full" - :disabled="isSubmitting || !email.trim() || password.length < 6 || (invitationCodeEnabled && !invitationCode.trim())" + :disabled="isSubmitting || !email.trim() || password.length < 6 || (invitationCodeEnabled && !invitationCode.trim()) || (turnstileEnabled && !turnstileToken)" @click="handleSubmit" > {{ isSubmitting ? t('common.processing') : t('auth.createAccount') }} @@ -284,6 +284,14 @@ async function handleSubmit() { return } + // Turnstile 票据一次性:发送验证码已消耗上一枚,reset 后要等新票据回调。 + // 缺票时不能提交——create-account 端点会校验验证码,空 token 直接被判失败。 + // 表单的隐式提交(输入框回车)绕得过按钮的 disabled,所以这里必须再挡一次。 + if (turnstileEnabled.value && !turnstileToken.value) { + sendCodeError.value = t('auth.completeVerification') + return + } + if (!(await acquireTencentProof())) { return } diff --git a/frontend/src/components/auth/__tests__/PendingOAuthCreateAccountForm.spec.ts b/frontend/src/components/auth/__tests__/PendingOAuthCreateAccountForm.spec.ts index 23111299c1..0485db1e9a 100644 --- a/frontend/src/components/auth/__tests__/PendingOAuthCreateAccountForm.spec.ts +++ b/frontend/src/components/auth/__tests__/PendingOAuthCreateAccountForm.spec.ts @@ -328,4 +328,99 @@ describe('PendingOAuthCreateAccountForm', () => { turnstile_token: 'turnstile-token' }) }) + + it('requires a turnstile token before submitting when turnstile is enabled', async () => { + getPublicSettings.mockResolvedValue({ + turnstile_enabled: true, + turnstile_site_key: 'site-key' + }) + + const wrapper = mount(PendingOAuthCreateAccountForm, { + props: { + testIdPrefix: 'linuxdo', + initialEmail: 'user@example.com', + isSubmitting: false + }, + global: { + stubs: { + TurnstileWidget: { + template: '', + methods: { reset: turnstileReset } + } + } + } + }) + + await flushPromises() + await wrapper.get('[data-testid="linuxdo-create-account-password"]').setValue('secret-123') + + expect(wrapper.get('[data-testid="linuxdo-create-account-submit"]').attributes('disabled')).toBeDefined() + + // 隐式提交(输入框回车)绕过按钮 disabled,仍不能带着空票据发出请求 + await wrapper.get('form').trigger('submit.prevent') + expect(wrapper.emitted('submit')).toBeUndefined() + + await wrapper.get('[data-testid="turnstile-verify"]').trigger('click') + + expect(wrapper.get('[data-testid="linuxdo-create-account-submit"]').attributes('disabled')).toBeUndefined() + + await wrapper.get('[data-testid="linuxdo-create-account-submit"]').trigger('click') + + expect(wrapper.emitted('submit')).toEqual([ + [ + { + email: 'user@example.com', + password: 'secret-123', + verifyCode: '', + turnstileToken: 'turnstile-token', + invitationCode: undefined + } + ] + ]) + }) + + it('blocks submit again after sending a verify code consumes the turnstile proof', async () => { + getPublicSettings.mockResolvedValue({ + turnstile_enabled: true, + turnstile_site_key: 'site-key' + }) + sendPendingOAuthVerifyCode.mockResolvedValue({ message: 'sent', countdown: 60 }) + + const wrapper = mount(PendingOAuthCreateAccountForm, { + props: { + testIdPrefix: 'linuxdo', + initialEmail: 'user@example.com', + isSubmitting: false + }, + global: { + stubs: { + TurnstileWidget: { + template: '', + methods: { reset: turnstileReset } + } + } + } + }) + + await flushPromises() + await wrapper.get('[data-testid="linuxdo-create-account-password"]').setValue('secret-123') + await wrapper.get('[data-testid="turnstile-verify"]').trigger('click') + await wrapper.get('[data-testid="linuxdo-create-account-send-code"]').trigger('click') + await flushPromises() + + // 发码消耗掉票据并 reset 组件,新票据回调前提交必须保持关闭 + expect(turnstileReset).toHaveBeenCalled() + expect(wrapper.get('[data-testid="linuxdo-create-account-submit"]').attributes('disabled')).toBeDefined() + + await wrapper.get('form').trigger('submit.prevent') + expect(wrapper.emitted('submit')).toBeUndefined() + + // 新票据回调后恢复可提交,且带上新票据 + await wrapper.get('[data-testid="turnstile-verify"]').trigger('click') + await wrapper.get('[data-testid="linuxdo-create-account-submit"]').trigger('click') + + expect(wrapper.emitted('submit')?.[0]?.[0]).toMatchObject({ + turnstileToken: 'turnstile-token' + }) + }) })