Add tfa enforce flow (#7805)

* add tfa enforce flow

* add 'tfa-secret' to recommended permissions

* fix if theme if user has dark mode

* oas: rename 'enable-2fa' to 'enable-tfa'

* Add required user fields

* Uniformize styling

* Fix direct and invalid routing

* Add required permission docs

* Fix typescript warnings

* Fix typescript warnings 2

* Allow auto theme

* Nest duplicate condition check

* Fix routing for users without role

* Follow page redirects

* Reduce the use of redirect query

* Improve error UX

* Allow admins to disable 2FA

* Improve autofocus UX

* Override update permission for 'tfa_secret' when role enforces TFA

* Remove permission requirements from docs

Co-authored-by: Jose Varela <joselcvarela@gmail.com>
Co-authored-by: rijkvanzanten <rijkvanzanten@me.com>
Co-authored-by: ian <licitdev@gmail.com>
This commit is contained in:
Nitwel
2022-06-15 19:27:59 +02:00
committed by GitHub
parent 3b2245918d
commit 37762358fc
13 changed files with 479 additions and 105 deletions

View File

@@ -1,6 +1,6 @@
<template>
<div>
<v-checkbox block :model-value="tfaEnabled" :disabled="!isCurrentUser" @click="toggle">
<v-checkbox block :model-value="tfaEnabled" :disabled="!isCurrentUser && !tfaEnabled" @click="toggle">
{{ tfaEnabled ? t('enabled') : t('disabled') }}
<div class="spacer" />
<template #append>
@@ -15,7 +15,7 @@
{{ t('enter_password_to_enable_tfa') }}
</v-card-title>
<v-card-text>
<v-input v-model="password" :nullable="false" type="password" :placeholder="t('password')" />
<v-input v-model="password" :nullable="false" type="password" :placeholder="t('password')" autofocus />
<v-error v-if="error" :error="error" />
</v-card-text>
@@ -28,37 +28,53 @@
<v-progress-circular v-else-if="loading === true" class="loader" indeterminate />
<div v-show="tfaEnabled === false && tfaGenerated === true && loading === false">
<form @submit.prevent="enableTFA">
<form @submit.prevent="enable">
<v-card-title>
{{ t('tfa_scan_code') }}
</v-card-title>
<v-card-text>
<canvas :id="canvasID" class="qr" />
<output class="secret selectable">{{ secret }}</output>
<v-input v-model="otp" type="text" :placeholder="t('otp')" :nullable="false" />
<v-input ref="inputOTP" v-model="otp" type="text" :placeholder="t('otp')" :nullable="false" />
<v-error v-if="error" :error="error" />
</v-card-text>
<v-card-actions>
<v-button type="button" secondary @click="cancelAndClose">{{ t('cancel') }}</v-button>
<v-button type="submit" :disabled="otp.length !== 6" @click="enableTFA">{{ t('done') }}</v-button>
<v-button type="submit" :disabled="otp.length !== 6" @click="enable">{{ t('done') }}</v-button>
</v-card-actions>
</form>
</div>
</v-card>
</v-dialog>
<v-dialog v-model="disableActive" @esc="disableActive = false">
<v-dialog v-model="disableActive" persistent @esc="cancelAndClose">
<v-card>
<form @submit.prevent="disableTFA">
<form v-if="isCurrentUser" @submit.prevent="disable">
<v-card-title>
{{ t('enter_otp_to_disable_tfa') }}
</v-card-title>
<v-card-text>
<v-input v-model="otp" type="text" :placeholder="t('otp')" :nullable="false" />
<v-input v-model="otp" type="text" :placeholder="t('otp')" :nullable="false" autofocus />
<v-error v-if="error" :error="error" />
</v-card-text>
<v-card-actions>
<v-button type="submit" kind="warning" :loading="loading" :disabled="otp.length !== 6">
<v-button type="button" secondary @click="cancelAndClose">{{ t('cancel') }}</v-button>
<v-button type="submit" kind="danger" :loading="loading" :disabled="otp.length !== 6">
{{ t('disable_tfa') }}
</v-button>
</v-card-actions>
</form>
<form v-else @submit.prevent="disable">
<v-card-title>
{{ t('disable_tfa') }}
</v-card-title>
<v-card-text>
{{ t('admin_disable_tfa_text') }}
<v-error v-if="error" :error="error" />
</v-card-text>
<v-card-actions>
<v-button type="button" secondary @click="cancelAndClose">{{ t('cancel') }}</v-button>
<v-button type="submit" kind="danger" :loading="loading">
{{ t('disable_tfa') }}
</v-button>
</v-card-actions>
@@ -70,11 +86,10 @@
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { defineComponent, ref, watch, onMounted, computed } from 'vue';
import api from '@/api';
import qrcode from 'qrcode';
import { nanoid } from 'nanoid';
import { defineComponent, ref, watch, computed, nextTick } from 'vue';
import { useUserStore } from '@/stores';
import { useTFASetup } from '@/composables/use-tfa-setup';
import { User } from '@directus/shared/types';
export default defineComponent({
props: {
@@ -91,20 +106,27 @@ export default defineComponent({
const { t } = useI18n();
const userStore = useUserStore();
const tfaEnabled = ref(!!props.value);
const tfaGenerated = ref(false);
const enableActive = ref(false);
const disableActive = ref(false);
const loading = ref(false);
const canvasID = nanoid();
const secret = ref<string>();
const otp = ref('');
const error = ref<any>();
const password = ref('');
onMounted(() => {
password.value = '';
});
const inputOTP = ref<any>(null);
const isCurrentUser = computed(() => (userStore.currentUser as User)?.id === props.primaryKey);
const {
generateTFA,
enableTFA,
disableTFA,
adminDisableTFA,
loading,
password,
tfaEnabled,
tfaGenerated,
secret,
otp,
error,
canvasID,
} = useTFASetup(!!props.value);
watch(
() => props.value,
@@ -113,7 +135,16 @@ export default defineComponent({
},
{ immediate: true }
);
const isCurrentUser = computed(() => userStore.currentUser?.id === props.primaryKey);
watch(
() => tfaGenerated.value,
async (generated) => {
if (generated) {
await nextTick();
(inputOTP.value.$el as HTMLElement).querySelector('input')!.focus();
}
}
);
return {
t,
@@ -121,7 +152,7 @@ export default defineComponent({
tfaGenerated,
generateTFA,
cancelAndClose,
enableTFA,
enable,
toggle,
password,
enableActive,
@@ -129,12 +160,27 @@ export default defineComponent({
loading,
canvasID,
secret,
disableTFA,
disable,
otp,
error,
isCurrentUser,
inputOTP,
};
async function enable() {
const success = await enableTFA();
enableActive.value = !success;
if (!success) {
(inputOTP.value.$el as HTMLElement).querySelector('input')!.focus();
}
}
async function disable() {
const success = await (isCurrentUser.value ? disableTFA() : adminDisableTFA(props.primaryKey));
disableActive.value = !success;
}
function toggle() {
if (tfaEnabled.value === false) {
enableActive.value = true;
@@ -143,68 +189,14 @@ export default defineComponent({
}
}
async function generateTFA() {
if (loading.value === true) return;
loading.value = true;
try {
const response = await api.post('/users/me/tfa/generate', { password: password.value });
const url = response.data.data.otpauth_url;
secret.value = response.data.data.secret;
await qrcode.toCanvas(document.getElementById(canvasID), url);
tfaGenerated.value = true;
error.value = null;
} catch (err: any) {
error.value = err;
} finally {
loading.value = false;
}
}
function cancelAndClose() {
tfaGenerated.value = false;
enableActive.value = false;
disableActive.value = false;
password.value = '';
otp.value = '';
secret.value = '';
}
async function enableTFA() {
if (loading.value === true) return;
loading.value = true;
try {
await api.post('/users/me/tfa/enable', { otp: otp.value, secret: secret.value });
tfaEnabled.value = true;
tfaGenerated.value = false;
enableActive.value = false;
password.value = '';
otp.value = '';
secret.value = '';
error.value = null;
} catch (err: any) {
error.value = err;
} finally {
loading.value = false;
}
}
async function disableTFA() {
loading.value = true;
try {
await api.post('/users/me/tfa/disable', { otp: otp.value });
tfaEnabled.value = false;
disableActive.value = false;
otp.value = '';
} catch (err: any) {
error.value = err;
} finally {
loading.value = false;
}
error.value = null;
}
},
});