feat(auth): prompt for TOTP code in the OIDC callback flow
When the backend reports that 2FA is required (412/1017), the OIDC callback view now shows a TOTP input and restarts the OIDC dance with the typed passcode stashed in localStorage so it can be submitted alongside a fresh authorization code. Refs GHSA-8jvc-mcx6-r4cg
This commit is contained in:
parent
546db0dc21
commit
b642b2a453
|
|
@ -81,6 +81,8 @@
|
|||
"authenticating": "Authenticating…",
|
||||
"openIdStateError": "State does not match, refusing to continue!",
|
||||
"openIdGeneralError": "An error occurred while authenticating against the third party.",
|
||||
"openIdTotpRequired": "Your account requires two-factor authentication. Enter your TOTP code and sign in again.",
|
||||
"openIdTotpSubmit": "Continue",
|
||||
"oauthMissingParams": "Missing required OAuth parameters: {params}",
|
||||
"oauthRedirectedToApp": "You have been redirected to the app. You can close this tab now.",
|
||||
"desktopTryDemo": "Try the Demo",
|
||||
|
|
|
|||
|
|
@ -13,9 +13,38 @@
|
|||
>
|
||||
{{ errorMessageFromQuery }}
|
||||
</Message>
|
||||
<Message v-if="loading">
|
||||
<Message v-if="loading && !needsTotp">
|
||||
{{ $t('user.auth.authenticating') }}
|
||||
</Message>
|
||||
|
||||
<form
|
||||
v-if="needsTotp"
|
||||
@submit.prevent="submitTotpAndRestart"
|
||||
>
|
||||
<Message class="mbe-2">
|
||||
{{ $t('user.auth.openIdTotpRequired') }}
|
||||
</Message>
|
||||
<FormField
|
||||
id="openIdTotpPasscode"
|
||||
ref="totpInput"
|
||||
v-model="totpPasscode"
|
||||
v-focus
|
||||
:label="$t('user.auth.totpTitle')"
|
||||
autocomplete="one-time-code"
|
||||
:placeholder="$t('user.auth.totpPlaceholder')"
|
||||
required
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
/>
|
||||
<XButton
|
||||
:loading="loading"
|
||||
:disabled="!totpPasscode"
|
||||
class="mbs-2"
|
||||
@click="submitTotpAndRestart"
|
||||
>
|
||||
{{ $t('user.auth.openIdTotpSubmit') }}
|
||||
</XButton>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -27,9 +56,13 @@ import {useI18n} from 'vue-i18n'
|
|||
|
||||
import {getErrorText} from '@/message'
|
||||
import Message from '@/components/misc/Message.vue'
|
||||
import FormField from '@/components/input/FormField.vue'
|
||||
import {useRedirectToLastVisited} from '@/composables/useRedirectToLastVisited'
|
||||
import {redirectToProvider} from '@/helpers/redirectToProvider'
|
||||
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
import type {IProvider} from '@/types/IProvider'
|
||||
|
||||
defineOptions({name: 'Auth'})
|
||||
|
||||
|
|
@ -39,11 +72,23 @@ const route = useRoute()
|
|||
const {redirectIfSaved} = useRedirectToLastVisited()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const configStore = useConfigStore()
|
||||
|
||||
const loading = computed(() => authStore.isLoading)
|
||||
const errorMessage = ref('')
|
||||
const errorMessageFromQuery = computed(() => route.query.error)
|
||||
|
||||
const needsTotp = ref(false)
|
||||
const totpPasscode = ref('')
|
||||
|
||||
function pendingTotpKey(provider: string): string {
|
||||
return `openid_pending_totp_${provider}`
|
||||
}
|
||||
|
||||
function findProvider(providerKey: string): IProvider | undefined {
|
||||
return configStore.auth.openidConnect.providers?.find((p: IProvider) => p.key === providerKey)
|
||||
}
|
||||
|
||||
async function authenticateWithCode() {
|
||||
// This component gets mounted twice: The first time when the actual auth request hits the frontend,
|
||||
// the second time after that auth request succeeded and the outer component "content-no-auth" isn't used
|
||||
|
|
@ -60,8 +105,11 @@ async function authenticateWithCode() {
|
|||
|
||||
errorMessage.value = ''
|
||||
|
||||
const providerKey = route.params.provider as string
|
||||
|
||||
if (typeof route.query.error !== 'undefined') {
|
||||
localStorage.removeItem('authenticating')
|
||||
sessionStorage.removeItem(pendingTotpKey(providerKey))
|
||||
errorMessage.value = typeof route.query.message !== 'undefined'
|
||||
? route.query.message as string
|
||||
: t('user.auth.openIdGeneralError')
|
||||
|
|
@ -71,23 +119,53 @@ async function authenticateWithCode() {
|
|||
const state = localStorage.getItem('state')
|
||||
if (typeof route.query.state === 'undefined' || route.query.state !== state) {
|
||||
localStorage.removeItem('authenticating')
|
||||
sessionStorage.removeItem(pendingTotpKey(providerKey))
|
||||
errorMessage.value = t('user.auth.openIdStateError')
|
||||
return
|
||||
}
|
||||
|
||||
// sessionStorage (not localStorage): per-tab, cleared on tab close.
|
||||
const pendingPasscode = sessionStorage.getItem(pendingTotpKey(providerKey)) ?? undefined
|
||||
if (pendingPasscode) {
|
||||
sessionStorage.removeItem(pendingTotpKey(providerKey))
|
||||
}
|
||||
|
||||
try {
|
||||
await authStore.openIdAuth({
|
||||
provider: route.params.provider,
|
||||
code: route.query.code,
|
||||
provider: providerKey,
|
||||
code: route.query.code as string,
|
||||
totpPasscode: pendingPasscode,
|
||||
})
|
||||
|
||||
redirectIfSaved()
|
||||
} catch (e) {
|
||||
const err = e as {response?: {data?: {code?: number}}}
|
||||
if (err?.response?.data?.code === 1017) {
|
||||
needsTotp.value = true
|
||||
return
|
||||
}
|
||||
errorMessage.value = getErrorText(e)
|
||||
} finally {
|
||||
localStorage.removeItem('authenticating')
|
||||
}
|
||||
}
|
||||
|
||||
async function submitTotpAndRestart() {
|
||||
if (!totpPasscode.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const providerKey = route.params.provider as string
|
||||
const provider = findProvider(providerKey)
|
||||
if (!provider) {
|
||||
errorMessage.value = t('user.auth.openIdGeneralError')
|
||||
return
|
||||
}
|
||||
|
||||
sessionStorage.setItem(pendingTotpKey(providerKey), totpPasscode.value)
|
||||
// The auth code is single-use; restart the OIDC flow so the next callback reads the stashed passcode.
|
||||
redirectToProvider(provider)
|
||||
}
|
||||
|
||||
onMounted(() => authenticateWithCode())
|
||||
</script>
|
||||
|
|
|
|||
Loading…
Reference in New Issue