mirror of
https://github.com/directus/directus.git
synced 2026-04-03 03:00:39 -04:00
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:
@@ -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;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user