Browse Source

working totp lifecycle

pull/1783/head
Bernd Storath 1 week ago
parent
commit
8fec51d87f
  1. 38
      src/app/composables/useSubmit.ts
  2. 131
      src/app/pages/me.vue
  3. 27
      src/server/api/me/totp.post.ts
  4. 27
      src/server/database/repositories/user/service.ts
  5. 20
      src/server/database/repositories/user/types.ts

38
src/app/composables/useSubmit.ts

@ -1,10 +1,32 @@
import type { NitroFetchRequest, NitroFetchOptions } from 'nitropack/types'; import type {
NitroFetchRequest,
NitroFetchOptions,
TypedInternalResponse,
ExtractedRouteMethod,
} from 'nitropack/types';
import { FetchError } from 'ofetch'; 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 = { type SubmitOpts<
revert: RevertFn; R extends NitroFetchRequest,
T = unknown,
O extends NitroFetchOptions<R> = NitroFetchOptions<R>,
> = {
revert: RevertFn<R, T, O>;
successMsg?: string; successMsg?: string;
errorMsg?: string; errorMsg?: string;
noSuccessToast?: boolean; noSuccessToast?: boolean;
@ -13,7 +35,8 @@ type SubmitOpts = {
export function useSubmit< export function useSubmit<
R extends NitroFetchRequest, R extends NitroFetchRequest,
O extends NitroFetchOptions<R> & { body?: never }, 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 toast = useToast();
const { t: $t } = useI18n(); const { t: $t } = useI18n();
@ -36,7 +59,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) { } catch (e) {
if (e instanceof FetchError) { if (e instanceof FetchError) {
toast.showToast({ toast.showToast({
@ -51,7 +75,7 @@ export function useSubmit<
} else { } else {
console.error(e); console.error(e);
} }
await opts.revert(false); await opts.revert(false, undefined);
} }
}; };
} }

131
src/app/pages/me.vue

@ -51,11 +51,12 @@
<FormElement @submit.prevent> <FormElement @submit.prevent>
<FormGroup> <FormGroup>
<FormHeading>{{ $t('general.2fa') }}</FormHeading> <FormHeading>{{ $t('general.2fa') }}</FormHeading>
<FormActionField <div
v-if="!authStore.userData?.totpVerified && !twofa" v-if="!authStore.userData?.totpVerified && !twofa"
:label="$t('me.enable2fa')" class="col-span-2 flex flex-col"
@click="enable2fa1" >
/> <FormActionField :label="$t('me.enable2fa')" @click="setup2fa" />
</div>
<div <div
v-if="!authStore.userData?.totpVerified && twofa" v-if="!authStore.userData?.totpVerified && twofa"
class="col-span-2" class="col-span-2"
@ -75,14 +76,14 @@
<p class="text-sm text-gray-500 dark:text-gray-400"> <p class="text-sm text-gray-500 dark:text-gray-400">
{{ $t('me.2faCodeDesc') }} {{ $t('me.2faCodeDesc') }}
</p> </p>
<FormNullTextField <FormTextField
id="2facode" id="2facode"
v-model="code" v-model="code"
:label="$t('me.2faCode')" :label="$t('me.2faCode')"
/> />
<FormActionField <FormActionField
:label="$t('me.enable2fa')" :label="$t('me.enable2fa')"
@click="enable2fa2" @click="enable2fa"
/> />
</div> </div>
</div> </div>
@ -93,6 +94,13 @@
<p class="text-sm text-gray-500 dark:text-gray-400"> <p class="text-sm text-gray-500 dark:text-gray-400">
{{ $t('me.disable2faDesc') }} {{ $t('me.disable2faDesc') }}
</p> </p>
<FormPasswordField
id="2fapassword"
v-model="disable2faPassword"
:label="$t('me.currentPassword')"
type="password"
autocomplete="current-password"
/>
<FormActionField <FormActionField
:label="$t('me.disable2fa')" :label="$t('me.disable2fa')"
@click="disable2fa" @click="disable2fa"
@ -158,53 +166,78 @@ function updatePassword() {
const twofa = ref<{ key: string; qrcode: string } | null>(null); const twofa = ref<{ key: string; qrcode: string } | null>(null);
async function enable2fa1() { const _setup2fa = useSubmit(
try { `/api/me/totp`,
const response = await $fetch('/api/me/totp', { {
method: 'post', method: 'post',
body: { },
code: null, {
}, revert: async (success, data) => {
}); if (success && data?.type === 'setup') {
if (!response.success && !response.type) { const qrcode = encodeQR(data.uri, 'svg', {
throw new Error('Failed to enable 2FA'); ecc: 'high',
} scale: 4,
if (response.type === 'create') { encoding: 'byte',
const qrcode = encodeQR(response.uri, 'svg', { });
ecc: 'high', const svg = new Blob([qrcode], { type: 'image/svg+xml' });
scale: 4, twofa.value = { key: data.key, qrcode: URL.createObjectURL(svg) };
encoding: 'byte', }
}); },
const svg = new Blob([qrcode], { type: 'image/svg+xml' });
twofa.value = { key: response.key, qrcode: URL.createObjectURL(svg) };
}
} catch (e) {
console.error(e);
} }
);
async function setup2fa() {
return _setup2fa({
type: 'setup',
});
} }
const code = ref<string | null>(null); const code = ref<string>('');
async function enable2fa2() { const _enable2fa = useSubmit(
try { `/api/me/totp`,
const response = await $fetch('/api/me/totp', { {
method: 'post', method: 'post',
body: { },
code: code.value, {
}, revert: async (success, data) => {
}); if (success && data?.type === 'created') {
if (!response.type) { authStore.update();
throw new Error('Failed to enable 2FA'); twofa.value = null;
} code.value = '';
if (response.type === 'created' && response.success) { }
authStore.update(); },
twofa.value = null;
code.value = null;
}
} catch (e) {
console.error(e);
} }
);
async function enable2fa() {
return _enable2fa({
type: 'create',
code: code.value,
});
} }
async function disable2fa() {} 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> </script>

27
src/server/api/me/totp.post.ts

@ -4,27 +4,26 @@ import { UserUpdateTotpSchema } from '#db/repositories/user/types';
type Response = type Response =
| { | {
success: boolean; success: boolean;
type: 'create'; type: 'setup';
key: string; key: string;
uri: string; uri: string;
} }
| { | { success: boolean; type: 'created' }
success: boolean; | { success: boolean; type: 'deleted' }
type: 'created'; | { success: boolean; type: 'error' };
};
export default definePermissionEventHandler( export default definePermissionEventHandler(
'me', 'me',
'update', 'update',
async ({ event, user, checkPermissions }) => { async ({ event, user, checkPermissions }) => {
const { code } = await readValidatedBody( const body = await readValidatedBody(
event, event,
validateZod(UserUpdateTotpSchema, event) validateZod(UserUpdateTotpSchema, event)
); );
checkPermissions(user); checkPermissions(user);
if (!code) { if (body.type === 'setup') {
const key = new Secret({ size: 20 }); const key = new Secret({ size: 20 });
const totp = new TOTP({ const totp = new TOTP({
@ -40,17 +39,25 @@ export default definePermissionEventHandler(
return { return {
success: true, success: true,
type: 'create', type: 'setup',
key: key.base32, key: key.base32,
uri: totp.toString(), uri: totp.toString(),
} as Response; } as Response;
} else { } else if (body.type === 'create') {
await Database.users.verifyTotp(user.id, code); await Database.users.verifyTotp(user.id, body.code);
return { return {
success: true, success: true,
type: 'created', type: 'created',
} as Response; } as Response;
} else if (body.type === 'delete') {
await Database.users.deleteTotpKey(user.id, body.currentPassword);
return {
success: true,
type: 'deleted',
} as Response;
} }
return { success: false, type: 'error' } as Response;
} }
); );

27
src/server/database/repositories/user/service.ts

@ -158,4 +158,31 @@ export class UserService {
.execute(); .execute();
}); });
} }
deleteTotpKey(id: ID, currentPassword: string) {
return this.#db.transaction(async (tx) => {
const txUser = await tx.query.user
.findFirst({ where: eq(user.id, id) })
.execute();
if (!txUser) {
throw new Error('User not found');
}
const passwordValid = await isPasswordValid(
currentPassword,
txUser.password
);
if (!passwordValid) {
throw new Error('Invalid password');
}
await tx
.update(user)
.set({ totpKey: null, totpVerified: false })
.where(eq(user.id, id))
.execute();
});
}
} }

20
src/server/database/repositories/user/types.ts

@ -59,10 +59,16 @@ export const UserUpdatePasswordSchema = z
message: t('zod.user.passwordMatch'), message: t('zod.user.passwordMatch'),
}); });
export const UserUpdateTotpSchema = z.object({ export const UserUpdateTotpSchema = z.union([
code: z z.object({
.string({ type: z.literal('setup'),
message: t('zod.user.totpCode'), }),
}) z.object({
.nullable(), type: z.literal('create'),
}); code: z.string().min(6, t('zod.user.totpCode')),
}),
z.object({
type: z.literal('delete'),
currentPassword: password,
}),
]);

Loading…
Cancel
Save