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:
@@ -4,7 +4,9 @@
|
||||
<slot />
|
||||
</template>
|
||||
<template #description>
|
||||
<img :src="qrCode" />
|
||||
<div class="bg-white">
|
||||
<img :src="qrCode" />
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<DialogClose>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
v-model.trim="data"
|
||||
:name="id"
|
||||
type="text"
|
||||
:autcomplete="autocomplete"
|
||||
:autocomplete="autocomplete"
|
||||
:placeholder="placeholder"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
v-model.trim="data"
|
||||
:name="id"
|
||||
type="text"
|
||||
:autcomplete="autocomplete"
|
||||
:autocomplete="autocomplete"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -22,6 +23,7 @@ defineProps<{
|
||||
label: string;
|
||||
description?: string;
|
||||
autocomplete?: string;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const data = defineModel<string>();
|
||||
|
||||
@@ -1,21 +1,42 @@
|
||||
import type { NitroFetchRequest, NitroFetchOptions } from 'nitropack/types';
|
||||
import type {
|
||||
NitroFetchRequest,
|
||||
NitroFetchOptions,
|
||||
TypedInternalResponse,
|
||||
ExtractedRouteMethod,
|
||||
} from 'nitropack/types';
|
||||
import { FetchError } from 'ofetch';
|
||||
|
||||
type RevertFn = (success: boolean) => Promise<void>;
|
||||
type RevertFn<
|
||||
R extends NitroFetchRequest,
|
||||
T = unknown,
|
||||
O extends NitroFetchOptions<R> = NitroFetchOptions<R>,
|
||||
> = (
|
||||
success: boolean,
|
||||
data:
|
||||
| TypedInternalResponse<
|
||||
R,
|
||||
T,
|
||||
NitroFetchOptions<R> extends O ? 'get' : ExtractedRouteMethod<R, O>
|
||||
>
|
||||
| undefined
|
||||
) => Promise<void>;
|
||||
|
||||
type SubmitOpts = {
|
||||
revert: RevertFn;
|
||||
type SubmitOpts<
|
||||
R extends NitroFetchRequest,
|
||||
T = unknown,
|
||||
O extends NitroFetchOptions<R> = NitroFetchOptions<R>,
|
||||
> = {
|
||||
revert: RevertFn<R, T, O>;
|
||||
successMsg?: string;
|
||||
errorMsg?: string;
|
||||
noSuccessToast?: boolean;
|
||||
};
|
||||
|
||||
export function useSubmit<
|
||||
R extends NitroFetchRequest,
|
||||
O extends NitroFetchOptions<R> & { body?: never },
|
||||
>(url: R, options: O, opts: SubmitOpts) {
|
||||
T = unknown,
|
||||
>(url: R, options: O, opts: SubmitOpts<R, T, O>) {
|
||||
const toast = useToast();
|
||||
const { t: $t } = useI18n();
|
||||
|
||||
return async (data: unknown) => {
|
||||
try {
|
||||
@@ -24,11 +45,6 @@ export function useSubmit<
|
||||
body: data,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (!(res as any).success) {
|
||||
throw new Error(opts.errorMsg || $t('toast.errored'));
|
||||
}
|
||||
|
||||
if (!opts.noSuccessToast) {
|
||||
toast.showToast({
|
||||
type: 'success',
|
||||
@@ -36,7 +52,8 @@ export function useSubmit<
|
||||
});
|
||||
}
|
||||
|
||||
await opts.revert(true);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await opts.revert(true, res as any);
|
||||
} catch (e) {
|
||||
if (e instanceof FetchError) {
|
||||
toast.showToast({
|
||||
@@ -51,7 +68,7 @@ export function useSubmit<
|
||||
} else {
|
||||
console.error(e);
|
||||
}
|
||||
await opts.revert(false);
|
||||
await opts.revert(false, undefined);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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