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…",
|
"authenticating": "Authenticating…",
|
||||||
"openIdStateError": "State does not match, refusing to continue!",
|
"openIdStateError": "State does not match, refusing to continue!",
|
||||||
"openIdGeneralError": "An error occurred while authenticating against the third party.",
|
"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}",
|
"oauthMissingParams": "Missing required OAuth parameters: {params}",
|
||||||
"oauthRedirectedToApp": "You have been redirected to the app. You can close this tab now.",
|
"oauthRedirectedToApp": "You have been redirected to the app. You can close this tab now.",
|
||||||
"desktopTryDemo": "Try the Demo",
|
"desktopTryDemo": "Try the Demo",
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,38 @@
|
||||||
>
|
>
|
||||||
{{ errorMessageFromQuery }}
|
{{ errorMessageFromQuery }}
|
||||||
</Message>
|
</Message>
|
||||||
<Message v-if="loading">
|
<Message v-if="loading && !needsTotp">
|
||||||
{{ $t('user.auth.authenticating') }}
|
{{ $t('user.auth.authenticating') }}
|
||||||
</Message>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -27,9 +56,13 @@ import {useI18n} from 'vue-i18n'
|
||||||
|
|
||||||
import {getErrorText} from '@/message'
|
import {getErrorText} from '@/message'
|
||||||
import Message from '@/components/misc/Message.vue'
|
import Message from '@/components/misc/Message.vue'
|
||||||
|
import FormField from '@/components/input/FormField.vue'
|
||||||
import {useRedirectToLastVisited} from '@/composables/useRedirectToLastVisited'
|
import {useRedirectToLastVisited} from '@/composables/useRedirectToLastVisited'
|
||||||
|
import {redirectToProvider} from '@/helpers/redirectToProvider'
|
||||||
|
|
||||||
import {useAuthStore} from '@/stores/auth'
|
import {useAuthStore} from '@/stores/auth'
|
||||||
|
import {useConfigStore} from '@/stores/config'
|
||||||
|
import type {IProvider} from '@/types/IProvider'
|
||||||
|
|
||||||
defineOptions({name: 'Auth'})
|
defineOptions({name: 'Auth'})
|
||||||
|
|
||||||
|
|
@ -39,11 +72,23 @@ const route = useRoute()
|
||||||
const {redirectIfSaved} = useRedirectToLastVisited()
|
const {redirectIfSaved} = useRedirectToLastVisited()
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const configStore = useConfigStore()
|
||||||
|
|
||||||
const loading = computed(() => authStore.isLoading)
|
const loading = computed(() => authStore.isLoading)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const errorMessageFromQuery = computed(() => route.query.error)
|
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() {
|
async function authenticateWithCode() {
|
||||||
// This component gets mounted twice: The first time when the actual auth request hits the frontend,
|
// 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
|
// 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 = ''
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
const providerKey = route.params.provider as string
|
||||||
|
|
||||||
if (typeof route.query.error !== 'undefined') {
|
if (typeof route.query.error !== 'undefined') {
|
||||||
localStorage.removeItem('authenticating')
|
localStorage.removeItem('authenticating')
|
||||||
|
sessionStorage.removeItem(pendingTotpKey(providerKey))
|
||||||
errorMessage.value = typeof route.query.message !== 'undefined'
|
errorMessage.value = typeof route.query.message !== 'undefined'
|
||||||
? route.query.message as string
|
? route.query.message as string
|
||||||
: t('user.auth.openIdGeneralError')
|
: t('user.auth.openIdGeneralError')
|
||||||
|
|
@ -71,23 +119,53 @@ async function authenticateWithCode() {
|
||||||
const state = localStorage.getItem('state')
|
const state = localStorage.getItem('state')
|
||||||
if (typeof route.query.state === 'undefined' || route.query.state !== state) {
|
if (typeof route.query.state === 'undefined' || route.query.state !== state) {
|
||||||
localStorage.removeItem('authenticating')
|
localStorage.removeItem('authenticating')
|
||||||
|
sessionStorage.removeItem(pendingTotpKey(providerKey))
|
||||||
errorMessage.value = t('user.auth.openIdStateError')
|
errorMessage.value = t('user.auth.openIdStateError')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sessionStorage (not localStorage): per-tab, cleared on tab close.
|
||||||
|
const pendingPasscode = sessionStorage.getItem(pendingTotpKey(providerKey)) ?? undefined
|
||||||
|
if (pendingPasscode) {
|
||||||
|
sessionStorage.removeItem(pendingTotpKey(providerKey))
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await authStore.openIdAuth({
|
await authStore.openIdAuth({
|
||||||
provider: route.params.provider,
|
provider: providerKey,
|
||||||
code: route.query.code,
|
code: route.query.code as string,
|
||||||
|
totpPasscode: pendingPasscode,
|
||||||
})
|
})
|
||||||
|
|
||||||
redirectIfSaved()
|
redirectIfSaved()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
const err = e as {response?: {data?: {code?: number}}}
|
||||||
|
if (err?.response?.data?.code === 1017) {
|
||||||
|
needsTotp.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
errorMessage.value = getErrorText(e)
|
errorMessage.value = getErrorText(e)
|
||||||
} finally {
|
} finally {
|
||||||
localStorage.removeItem('authenticating')
|
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())
|
onMounted(() => authenticateWithCode())
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue