Browse Source

working totp lifecycle

pull/1783/head
Bernd Storath 6 days 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';
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;
@ -13,7 +35,8 @@ type SubmitOpts = {
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();
@ -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);
}
};
}

131
src/app/pages/me.vue

@ -51,11 +51,12 @@
<FormElement @submit.prevent>
<FormGroup>
<FormHeading>{{ $t('general.2fa') }}</FormHeading>
<FormActionField
<div
v-if="!authStore.userData?.totpVerified && !twofa"
:label="$t('me.enable2fa')"
@click="enable2fa1"
/>
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"
@ -75,14 +76,14 @@
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ $t('me.2faCodeDesc') }}
</p>
<FormNullTextField
<FormTextField
id="2facode"
v-model="code"
:label="$t('me.2faCode')"
/>
<FormActionField
:label="$t('me.enable2fa')"
@click="enable2fa2"
@click="enable2fa"
/>
</div>
</div>
@ -93,6 +94,13 @@
<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"
@ -158,53 +166,78 @@ function updatePassword() {
const twofa = ref<{ key: string; qrcode: string } | null>(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<string | null>(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<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,
});
}
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>

27
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;
}
);

27
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();
});
}
}

20
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,
}),
]);

Loading…
Cancel
Save