feat: support linuxdo pending bind 2fa callback
This commit is contained in:
@@ -196,6 +196,9 @@ export interface OAuthTokenResponse {
|
|||||||
export interface PendingOAuthBindLoginResponse extends Partial<OAuthTokenResponse> {
|
export interface PendingOAuthBindLoginResponse extends Partial<OAuthTokenResponse> {
|
||||||
redirect?: string
|
redirect?: string
|
||||||
error?: string
|
error?: string
|
||||||
|
requires_2fa?: boolean
|
||||||
|
temp_token?: string
|
||||||
|
user_email_masked?: string
|
||||||
adoption_required?: boolean
|
adoption_required?: boolean
|
||||||
suggested_display_name?: string
|
suggested_display_name?: string
|
||||||
suggested_avatar_url?: string
|
suggested_avatar_url?: string
|
||||||
|
|||||||
@@ -12,7 +12,13 @@
|
|||||||
|
|
||||||
<transition name="fade">
|
<transition name="fade">
|
||||||
<div
|
<div
|
||||||
v-if="needsInvitation || needsAdoptionConfirmation || needsCreateAccount || needsBindLogin"
|
v-if="
|
||||||
|
needsInvitation ||
|
||||||
|
needsAdoptionConfirmation ||
|
||||||
|
needsCreateAccount ||
|
||||||
|
needsBindLogin ||
|
||||||
|
needsTotpChallenge
|
||||||
|
"
|
||||||
class="space-y-4"
|
class="space-y-4"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -186,6 +192,40 @@
|
|||||||
</p>
|
</p>
|
||||||
</transition>
|
</transition>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="needsTotpChallenge">
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
Enter the 6-digit verification code for
|
||||||
|
<span class="font-medium">{{ totpUserEmailMasked || 'your account' }}</span>
|
||||||
|
to finish binding this LinuxDo sign-in.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<input
|
||||||
|
v-model="totpCode"
|
||||||
|
data-testid="linuxdo-bind-login-totp"
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="6"
|
||||||
|
class="input w-full"
|
||||||
|
placeholder="123456"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
@keyup.enter="handleSubmitTotpChallenge"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
data-testid="linuxdo-bind-login-totp-submit"
|
||||||
|
class="btn btn-primary w-full"
|
||||||
|
:disabled="isSubmitting || totpCode.trim().length !== 6"
|
||||||
|
@click="handleSubmitTotpChallenge"
|
||||||
|
>
|
||||||
|
{{ isSubmitting ? t('common.processing') : 'Verify and continue' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<transition name="fade">
|
||||||
|
<p v-if="totpError" class="text-sm text-red-600 dark:text-red-400">
|
||||||
|
{{ totpError }}
|
||||||
|
</p>
|
||||||
|
</transition>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
|
|
||||||
@@ -226,6 +266,7 @@ import {
|
|||||||
exchangePendingOAuthCompletion,
|
exchangePendingOAuthCompletion,
|
||||||
getOAuthCompletionKind,
|
getOAuthCompletionKind,
|
||||||
isOAuthLoginCompletion,
|
isOAuthLoginCompletion,
|
||||||
|
login2FA,
|
||||||
persistOAuthTokenContext,
|
persistOAuthTokenContext,
|
||||||
type OAuthAdoptionDecision,
|
type OAuthAdoptionDecision,
|
||||||
type PendingOAuthExchangeResponse
|
type PendingOAuthExchangeResponse
|
||||||
@@ -260,6 +301,11 @@ const bindLoginPassword = ref('')
|
|||||||
const accountActionError = ref('')
|
const accountActionError = ref('')
|
||||||
const canReturnToCreateAccount = ref(false)
|
const canReturnToCreateAccount = ref(false)
|
||||||
const bindSuccessMessage = t('profile.authBindings.bindSuccess')
|
const bindSuccessMessage = t('profile.authBindings.bindSuccess')
|
||||||
|
const needsTotpChallenge = ref(false)
|
||||||
|
const totpTempToken = ref('')
|
||||||
|
const totpCode = ref('')
|
||||||
|
const totpError = ref('')
|
||||||
|
const totpUserEmailMasked = ref('')
|
||||||
|
|
||||||
const needsCreateAccount = computed(() => pendingAccountAction.value === 'create_account')
|
const needsCreateAccount = computed(() => pendingAccountAction.value === 'create_account')
|
||||||
const needsBindLogin = computed(() => pendingAccountAction.value === 'bind_login')
|
const needsBindLogin = computed(() => pendingAccountAction.value === 'bind_login')
|
||||||
@@ -346,6 +392,11 @@ function applyPendingAccountAction(completion: LinuxDoPendingActionResponse) {
|
|||||||
const action = resolvePendingAccountAction(completion)
|
const action = resolvePendingAccountAction(completion)
|
||||||
pendingAccountAction.value = action
|
pendingAccountAction.value = action
|
||||||
accountActionError.value = ''
|
accountActionError.value = ''
|
||||||
|
needsTotpChallenge.value = false
|
||||||
|
totpTempToken.value = ''
|
||||||
|
totpCode.value = ''
|
||||||
|
totpError.value = ''
|
||||||
|
totpUserEmailMasked.value = ''
|
||||||
|
|
||||||
const email = extractPendingAccountEmail(completion)
|
const email = extractPendingAccountEmail(completion)
|
||||||
if (action === 'create_account') {
|
if (action === 'create_account') {
|
||||||
@@ -364,6 +415,23 @@ function applyPendingAccountAction(completion: LinuxDoPendingActionResponse) {
|
|||||||
canReturnToCreateAccount.value = false
|
canReturnToCreateAccount.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyTotpChallenge(completion: LinuxDoPendingActionResponse): boolean {
|
||||||
|
if (completion.requires_2fa !== true || !completion.temp_token) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingAccountAction.value = 'none'
|
||||||
|
needsInvitation.value = false
|
||||||
|
needsAdoptionConfirmation.value = false
|
||||||
|
needsTotpChallenge.value = true
|
||||||
|
totpTempToken.value = completion.temp_token
|
||||||
|
totpCode.value = ''
|
||||||
|
totpError.value = ''
|
||||||
|
totpUserEmailMasked.value = completion.user_email_masked || ''
|
||||||
|
isProcessing.value = false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
function switchToBindLoginMode() {
|
function switchToBindLoginMode() {
|
||||||
pendingAccountAction.value = 'bind_login'
|
pendingAccountAction.value = 'bind_login'
|
||||||
bindLoginEmail.value = bindLoginEmail.value.trim() || pendingAccountEmail.value.trim()
|
bindLoginEmail.value = bindLoginEmail.value.trim() || pendingAccountEmail.value.trim()
|
||||||
@@ -412,6 +480,10 @@ async function finalizePendingAccountResponse(completion: LinuxDoPendingActionRe
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (applyTotpChallenge(completion)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
applyPendingAccountAction(completion)
|
applyPendingAccountAction(completion)
|
||||||
if (pendingAccountAction.value !== 'none') {
|
if (pendingAccountAction.value !== 'none') {
|
||||||
needsInvitation.value = false
|
needsInvitation.value = false
|
||||||
@@ -501,6 +573,27 @@ async function handleBindLogin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSubmitTotpChallenge() {
|
||||||
|
totpError.value = ''
|
||||||
|
const code = totpCode.value.trim()
|
||||||
|
if (!totpTempToken.value || code.length !== 6) return
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const completion = await login2FA({
|
||||||
|
temp_token: totpTempToken.value,
|
||||||
|
totp_code: code
|
||||||
|
})
|
||||||
|
await authStore.setToken(completion.access_token)
|
||||||
|
appStore.showSuccess(t('auth.loginSuccess'))
|
||||||
|
await router.replace(redirectTo.value)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
totpError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const params = parseFragmentParams()
|
const params = parseFragmentParams()
|
||||||
const error = params.get('error')
|
const error = params.get('error')
|
||||||
@@ -527,6 +620,10 @@ onMounted(async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (applyTotpChallenge(completion as LinuxDoPendingActionResponse)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
applyPendingAccountAction(completion as LinuxDoPendingActionResponse)
|
applyPendingAccountAction(completion as LinuxDoPendingActionResponse)
|
||||||
if (pendingAccountAction.value !== 'none') {
|
if (pendingAccountAction.value !== 'none') {
|
||||||
isProcessing.value = false
|
isProcessing.value = false
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const showError = vi.fn()
|
|||||||
const setToken = vi.fn()
|
const setToken = vi.fn()
|
||||||
const exchangePendingOAuthCompletion = vi.fn()
|
const exchangePendingOAuthCompletion = vi.fn()
|
||||||
const completeLinuxDoOAuthRegistration = vi.fn()
|
const completeLinuxDoOAuthRegistration = vi.fn()
|
||||||
|
const login2FA = vi.fn()
|
||||||
const apiClientPost = vi.fn()
|
const apiClientPost = vi.fn()
|
||||||
|
|
||||||
vi.mock('vue-router', () => ({
|
vi.mock('vue-router', () => ({
|
||||||
@@ -51,7 +52,8 @@ vi.mock('@/api/auth', async () => {
|
|||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletion(...args),
|
exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletion(...args),
|
||||||
completeLinuxDoOAuthRegistration: (...args: any[]) => completeLinuxDoOAuthRegistration(...args)
|
completeLinuxDoOAuthRegistration: (...args: any[]) => completeLinuxDoOAuthRegistration(...args),
|
||||||
|
login2FA: (...args: any[]) => login2FA(...args)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -63,6 +65,7 @@ describe('LinuxDoCallbackView', () => {
|
|||||||
setToken.mockReset()
|
setToken.mockReset()
|
||||||
exchangePendingOAuthCompletion.mockReset()
|
exchangePendingOAuthCompletion.mockReset()
|
||||||
completeLinuxDoOAuthRegistration.mockReset()
|
completeLinuxDoOAuthRegistration.mockReset()
|
||||||
|
login2FA.mockReset()
|
||||||
apiClientPost.mockReset()
|
apiClientPost.mockReset()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -344,4 +347,57 @@ describe('LinuxDoCallbackView', () => {
|
|||||||
expect(setToken).toHaveBeenCalledWith('bind-access-token')
|
expect(setToken).toHaveBeenCalledWith('bind-access-token')
|
||||||
expect(replace).toHaveBeenCalledWith('/profile/security')
|
expect(replace).toHaveBeenCalledWith('/profile/security')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('handles bind-login 2FA challenge before redirecting', async () => {
|
||||||
|
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||||
|
error: 'bind_login_required',
|
||||||
|
redirect: '/profile',
|
||||||
|
email: 'existing@example.com',
|
||||||
|
adoption_required: true,
|
||||||
|
suggested_display_name: 'LinuxDo Nick',
|
||||||
|
suggested_avatar_url: 'https://cdn.example/linuxdo.png'
|
||||||
|
})
|
||||||
|
apiClientPost.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
requires_2fa: true,
|
||||||
|
temp_token: 'temp-123',
|
||||||
|
user_email_masked: 'o***g@example.com'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
login2FA.mockResolvedValue({
|
||||||
|
access_token: '2fa-access-token'
|
||||||
|
})
|
||||||
|
setToken.mockResolvedValue({})
|
||||||
|
|
||||||
|
const wrapper = mount(LinuxDoCallbackView, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
AuthLayout: { template: '<div><slot /></div>' },
|
||||||
|
Icon: true,
|
||||||
|
RouterLink: { template: '<a><slot /></a>' },
|
||||||
|
transition: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.get('[data-testid="linuxdo-bind-login-password"]').setValue('secret-password')
|
||||||
|
await wrapper.get('[data-testid="linuxdo-bind-login-submit"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('o***g@example.com')
|
||||||
|
expect(login2FA).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
await wrapper.get('[data-testid="linuxdo-bind-login-totp"]').setValue('123456')
|
||||||
|
await wrapper.get('[data-testid="linuxdo-bind-login-totp-submit"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(login2FA).toHaveBeenCalledWith({
|
||||||
|
temp_token: 'temp-123',
|
||||||
|
totp_code: '123456'
|
||||||
|
})
|
||||||
|
expect(setToken).toHaveBeenCalledWith('2fa-access-token')
|
||||||
|
expect(replace).toHaveBeenCalledWith('/profile')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user