From 8fec51d87f85afa4e757b86adc7afae19351b4c7 Mon Sep 17 00:00:00 2001 From: Bernd Storath <999999bst@gmail.com> Date: Tue, 1 Apr 2025 11:07:24 +0200 Subject: [PATCH] working totp lifecycle --- src/app/composables/useSubmit.ts | 38 ++++- src/app/pages/me.vue | 131 +++++++++++------- src/server/api/me/totp.post.ts | 27 ++-- .../database/repositories/user/service.ts | 27 ++++ .../database/repositories/user/types.ts | 20 ++- 5 files changed, 170 insertions(+), 73 deletions(-) diff --git a/src/app/composables/useSubmit.ts b/src/app/composables/useSubmit.ts index 27d734c1..048aadfc 100644 --- a/src/app/composables/useSubmit.ts +++ b/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'; -type RevertFn = (success: boolean) => Promise; +type RevertFn< + R extends NitroFetchRequest, + T = unknown, + O extends NitroFetchOptions = NitroFetchOptions, +> = ( + success: boolean, + data: + | TypedInternalResponse< + R, + T, + NitroFetchOptions extends O ? 'get' : ExtractedRouteMethod + > + | undefined +) => Promise; -type SubmitOpts = { - revert: RevertFn; +type SubmitOpts< + R extends NitroFetchRequest, + T = unknown, + O extends NitroFetchOptions = NitroFetchOptions, +> = { + revert: RevertFn; successMsg?: string; errorMsg?: string; noSuccessToast?: boolean; @@ -13,7 +35,8 @@ type SubmitOpts = { export function useSubmit< R extends NitroFetchRequest, O extends NitroFetchOptions & { body?: never }, ->(url: R, options: O, opts: SubmitOpts) { + T = unknown, +>(url: R, options: O, opts: SubmitOpts) { const toast = useToast(); 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) { if (e instanceof FetchError) { toast.showToast({ @@ -51,7 +75,7 @@ export function useSubmit< } else { console.error(e); } - await opts.revert(false); + await opts.revert(false, undefined); } }; } diff --git a/src/app/pages/me.vue b/src/app/pages/me.vue index 86cf00f1..57bc79b3 100644 --- a/src/app/pages/me.vue +++ b/src/app/pages/me.vue @@ -51,11 +51,12 @@ {{ $t('general.2fa') }} - + class="col-span-2 flex flex-col" + > + +
{{ $t('me.2faCodeDesc') }}

-
@@ -93,6 +94,13 @@

{{ $t('me.disable2faDesc') }}

+ (null); -async function enable2fa1() { - try { - const response = await $fetch('/api/me/totp', { - method: 'post', - body: { - code: null, - }, - }); - if (!response.success && !response.type) { - throw new Error('Failed to enable 2FA'); - } - if (response.type === 'create') { - const qrcode = encodeQR(response.uri, 'svg', { - ecc: 'high', - scale: 4, - 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); +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(null); - -async function enable2fa2() { - try { - const response = await $fetch('/api/me/totp', { - method: 'post', - body: { - code: code.value, - }, - }); - if (!response.type) { - throw new Error('Failed to enable 2FA'); - } - if (response.type === 'created' && response.success) { - authStore.update(); - twofa.value = null; - code.value = null; - } - } catch (e) { - console.error(e); +const code = ref(''); + +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, + }); } -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, + }); +} diff --git a/src/server/api/me/totp.post.ts b/src/server/api/me/totp.post.ts index 8b37c142..99d0fc6e 100644 --- a/src/server/api/me/totp.post.ts +++ b/src/server/api/me/totp.post.ts @@ -4,27 +4,26 @@ import { UserUpdateTotpSchema } from '#db/repositories/user/types'; type Response = | { success: boolean; - type: 'create'; + type: 'setup'; key: string; uri: string; } - | { - success: boolean; - type: 'created'; - }; + | { success: boolean; type: 'created' } + | { success: boolean; type: 'deleted' } + | { success: boolean; type: 'error' }; export default definePermissionEventHandler( 'me', 'update', async ({ event, user, checkPermissions }) => { - const { code } = await readValidatedBody( + const body = await readValidatedBody( event, validateZod(UserUpdateTotpSchema, event) ); checkPermissions(user); - if (!code) { + if (body.type === 'setup') { const key = new Secret({ size: 20 }); const totp = new TOTP({ @@ -40,17 +39,25 @@ export default definePermissionEventHandler( return { success: true, - type: 'create', + type: 'setup', key: key.base32, uri: totp.toString(), } as Response; - } else { - await Database.users.verifyTotp(user.id, code); + } else if (body.type === 'create') { + await Database.users.verifyTotp(user.id, body.code); return { success: true, type: 'created', } 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; } ); diff --git a/src/server/database/repositories/user/service.ts b/src/server/database/repositories/user/service.ts index 995050b5..4c139ab4 100644 --- a/src/server/database/repositories/user/service.ts +++ b/src/server/database/repositories/user/service.ts @@ -158,4 +158,31 @@ export class UserService { .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(); + }); + } } diff --git a/src/server/database/repositories/user/types.ts b/src/server/database/repositories/user/types.ts index ae34e52e..0d0100d1 100644 --- a/src/server/database/repositories/user/types.ts +++ b/src/server/database/repositories/user/types.ts @@ -59,10 +59,16 @@ export const UserUpdatePasswordSchema = z message: t('zod.user.passwordMatch'), }); -export const UserUpdateTotpSchema = z.object({ - code: z - .string({ - message: t('zod.user.totpCode'), - }) - .nullable(), -}); +export const UserUpdateTotpSchema = z.union([ + z.object({ + type: z.literal('setup'), + }), + z.object({ + type: z.literal('create'), + code: z.string().min(6, t('zod.user.totpCode')), + }), + z.object({ + type: z.literal('delete'), + currentPassword: password, + }), +]);