fix(auth-ui): gate pending OAuth account creation on a captcha proof

#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).
This commit is contained in:
shaw
2026-08-04 20:29:53 +08:00
parent 8b3fe664dc
commit 635a27189f
2 changed files with 104 additions and 1 deletions
@@ -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
}
@@ -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: '<button data-testid="turnstile-verify" @click="$emit(\'verify\', \'turnstile-token\')">verify</button>',
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: '<button data-testid="turnstile-verify" @click="$emit(\'verify\', \'turnstile-token\')">verify</button>',
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'
})
})
})