Feat: 2fa (#1783)
* preplan otp, better qrcode library * add 2fa as feature * add totp generation * working totp lifecycle * don't allow disabled user to log in not a security issue as permission handler would fail anyway * require 2fa on login if enabled * update packages * fix typo * remove console.logs
This commit is contained in:
@@ -86,7 +86,6 @@ const _changeCidr = useSubmit(
|
||||
{
|
||||
revert,
|
||||
successMsg: t('admin.interface.cidrSuccess'),
|
||||
errorMsg: t('admin.interface.cidrError'),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -102,7 +101,6 @@ const _restartInterface = useSubmit(
|
||||
{
|
||||
revert,
|
||||
successMsg: t('admin.interface.restartSuccess'),
|
||||
errorMsg: t('admin.interface.restartError'),
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
+44
-7
@@ -30,6 +30,18 @@
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
|
||||
<BaseInput
|
||||
v-if="totpRequired"
|
||||
v-model="totp"
|
||||
type="text"
|
||||
name="totp"
|
||||
:placeholder="$t('general.2faCode')"
|
||||
autocomplete="one-time-code"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
pattern="\d{6}"
|
||||
/>
|
||||
|
||||
<label
|
||||
class="flex gap-2 whitespace-nowrap"
|
||||
:title="$t('login.rememberMeDesc')"
|
||||
@@ -58,10 +70,15 @@
|
||||
const authStore = useAuthStore();
|
||||
authStore.update();
|
||||
|
||||
const toast = useToast();
|
||||
const { t } = useI18n();
|
||||
|
||||
const authenticating = ref(false);
|
||||
const remember = ref(false);
|
||||
const username = ref<null | string>(null);
|
||||
const password = ref<null | string>(null);
|
||||
const username = ref<string>('');
|
||||
const password = ref<string>('');
|
||||
const totpRequired = ref(false);
|
||||
const totp = ref<string>('');
|
||||
|
||||
const _submit = useSubmit(
|
||||
'/api/session',
|
||||
@@ -69,13 +86,32 @@ const _submit = useSubmit(
|
||||
method: 'post',
|
||||
},
|
||||
{
|
||||
revert: async (success) => {
|
||||
authenticating.value = false;
|
||||
password.value = null;
|
||||
|
||||
revert: async (success, data) => {
|
||||
if (success) {
|
||||
await navigateTo('/');
|
||||
if (data?.status === 'success') {
|
||||
await navigateTo('/');
|
||||
} else if (data?.status === 'TOTP_REQUIRED') {
|
||||
authenticating.value = false;
|
||||
totpRequired.value = true;
|
||||
toast.showToast({
|
||||
title: t('general.2fa'),
|
||||
message: t('login.2faRequired'),
|
||||
type: 'error',
|
||||
});
|
||||
return;
|
||||
} else if (data?.status === 'INVALID_TOTP_CODE') {
|
||||
authenticating.value = false;
|
||||
totp.value = '';
|
||||
toast.showToast({
|
||||
title: t('general.2fa'),
|
||||
message: t('login.2faWrong'),
|
||||
type: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
authenticating.value = false;
|
||||
password.value = '';
|
||||
},
|
||||
noSuccessToast: true,
|
||||
}
|
||||
@@ -90,6 +126,7 @@ async function submit() {
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
remember: remember.value,
|
||||
totpCode: totpRequired.value ? totp.value : undefined,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -48,12 +48,74 @@
|
||||
/>
|
||||
</FormGroup>
|
||||
</FormElement>
|
||||
<FormElement @submit.prevent>
|
||||
<FormGroup>
|
||||
<FormHeading>{{ $t('general.2fa') }}</FormHeading>
|
||||
<div
|
||||
v-if="!authStore.userData?.totpVerified && !twofa"
|
||||
class="col-span-2 flex flex-col"
|
||||
>
|
||||
<FormActionField :label="$t('me.enable2fa')" @click="setup2fa" />
|
||||
</div>
|
||||
<div
|
||||
v-if="!authStore.userData?.totpVerified && twofa"
|
||||
class="col-span-2"
|
||||
>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ $t('me.enable2faDesc') }}
|
||||
</p>
|
||||
<div class="mt-2 flex flex-col gap-2">
|
||||
<img :src="twofa.qrcode" size="128" class="bg-white" />
|
||||
<FormTextField
|
||||
id="2fakey"
|
||||
:model-value="twofa.key"
|
||||
:on-update:model-value="() => {}"
|
||||
:label="$t('me.2faKey')"
|
||||
:disabled="true"
|
||||
/>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ $t('me.2faCodeDesc') }}
|
||||
</p>
|
||||
<FormTextField
|
||||
id="2facode"
|
||||
v-model="code"
|
||||
:label="$t('general.2faCode')"
|
||||
/>
|
||||
<FormActionField
|
||||
:label="$t('me.enable2fa')"
|
||||
@click="enable2fa"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="authStore.userData?.totpVerified"
|
||||
class="col-span-2 flex flex-col gap-2"
|
||||
>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ $t('me.disable2faDesc') }}
|
||||
</p>
|
||||
<FormPasswordField
|
||||
id="2fapassword"
|
||||
v-model="disable2faPassword"
|
||||
:label="$t('me.currentPassword')"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<FormActionField
|
||||
:label="$t('me.disable2fa')"
|
||||
@click="disable2fa"
|
||||
/>
|
||||
</div>
|
||||
</FormGroup>
|
||||
</FormElement>
|
||||
</PanelBody>
|
||||
</Panel>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { encodeQR } from 'qr';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
authStore.update();
|
||||
|
||||
@@ -101,4 +163,81 @@ function updatePassword() {
|
||||
confirmPassword: confirmPassword.value,
|
||||
});
|
||||
}
|
||||
|
||||
const twofa = ref<{ key: string; qrcode: string } | null>(null);
|
||||
|
||||
const _setup2fa = useSubmit(
|
||||
`/api/me/totp`,
|
||||
{
|
||||
method: 'post',
|
||||
},
|
||||
{
|
||||
revert: async (success, data) => {
|
||||
if (success && data?.type === 'setup') {
|
||||
const qrcode = encodeQR(data.uri, 'svg', {
|
||||
ecc: 'high',
|
||||
scale: 4,
|
||||
encoding: 'byte',
|
||||
});
|
||||
const svg = new Blob([qrcode], { type: 'image/svg+xml' });
|
||||
twofa.value = { key: data.key, qrcode: URL.createObjectURL(svg) };
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
async function setup2fa() {
|
||||
return _setup2fa({
|
||||
type: 'setup',
|
||||
});
|
||||
}
|
||||
|
||||
const code = ref<string>('');
|
||||
|
||||
const _enable2fa = useSubmit(
|
||||
`/api/me/totp`,
|
||||
{
|
||||
method: 'post',
|
||||
},
|
||||
{
|
||||
revert: async (success, data) => {
|
||||
if (success && data?.type === 'created') {
|
||||
authStore.update();
|
||||
twofa.value = null;
|
||||
code.value = '';
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
async function enable2fa() {
|
||||
return _enable2fa({
|
||||
type: 'create',
|
||||
code: code.value,
|
||||
});
|
||||
}
|
||||
|
||||
const disable2faPassword = ref('');
|
||||
|
||||
const _disable2fa = useSubmit(
|
||||
`/api/me/totp`,
|
||||
{
|
||||
method: 'post',
|
||||
},
|
||||
{
|
||||
revert: async (success, data) => {
|
||||
if (success && data?.type === 'deleted') {
|
||||
authStore.update();
|
||||
disable2faPassword.value = '';
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
async function disable2fa() {
|
||||
return _disable2fa({
|
||||
type: 'delete',
|
||||
currentPassword: disable2faPassword.value,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user