* 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:
Bernd Storath
2025-04-01 14:43:48 +02:00
committed by GitHub
parent 1c7f64ebd5
commit 32b73b850a
24 changed files with 804 additions and 438 deletions
+3 -1
View File
@@ -4,7 +4,9 @@
<slot />
</template>
<template #description>
<img :src="qrCode" />
<div class="bg-white">
<img :src="qrCode" />
</div>
</template>
<template #actions>
<DialogClose>
+1 -1
View File
@@ -12,7 +12,7 @@
v-model.trim="data"
:name="id"
type="text"
:autcomplete="autocomplete"
:autocomplete="autocomplete"
:placeholder="placeholder"
/>
</template>
+3 -1
View File
@@ -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>();
+31 -14
View File
@@ -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);
}
};
}
-2
View File
@@ -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
View File
@@ -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>
+139
View File
@@ -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>