Add support for OTPs and enhance sensitive apis with OTP verification (#191)

One-Time Password (OTP) package added with comprehensive token management, including OTP verification for team account deletion and ownership transfer.
This commit is contained in:
Giancarlo Buomprisco
2025-03-01 16:35:09 +07:00
committed by GitHub
parent 20f7fd2c22
commit d31f3eb993
60 changed files with 3543 additions and 1363 deletions

View File

@@ -27,6 +27,7 @@
"@kit/mailers": "workspace:*",
"@kit/monitoring": "workspace:*",
"@kit/next": "workspace:*",
"@kit/otp": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",

View File

@@ -4,9 +4,11 @@ import { useFormStatus } from 'react-dom';
import { zodResolver } from '@hookform/resolvers/zod';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { useForm } from 'react-hook-form';
import { useForm, useWatch } from 'react-hook-form';
import { ErrorBoundary } from '@kit/monitoring/components';
import { VerifyOtpForm } from '@kit/otp/components';
import { useUser } from '@kit/supabase/hooks/use-user';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import {
AlertDialog,
@@ -18,8 +20,7 @@ import {
AlertDialogTrigger,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
import { Form, FormControl, FormItem, FormLabel } from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { Form } from '@kit/ui/form';
import { Trans } from '@kit/ui/trans';
import { DeletePersonalAccountSchema } from '../../schema/delete-personal-account.schema';
@@ -46,6 +47,12 @@ export function AccountDangerZone() {
}
function DeleteAccountModal() {
const { data: user } = useUser();
if (!user?.email) {
return null;
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>
@@ -61,22 +68,39 @@ function DeleteAccountModal() {
</AlertDialogTitle>
</AlertDialogHeader>
<ErrorBoundary fallback={<DeleteAccountErrorAlert />}>
<DeleteAccountForm />
<ErrorBoundary fallback={<DeleteAccountErrorContainer />}>
<DeleteAccountForm email={user.email} />
</ErrorBoundary>
</AlertDialogContent>
</AlertDialog>
);
}
function DeleteAccountForm() {
function DeleteAccountForm(props: { email: string }) {
const form = useForm({
resolver: zodResolver(DeletePersonalAccountSchema),
defaultValues: {
confirmation: '' as 'DELETE'
otp: '',
},
});
const { otp } = useWatch({ control: form.control });
if (!otp) {
return (
<VerifyOtpForm
purpose={'delete-personal-account'}
email={props.email}
onSuccess={(otp) => form.setValue('otp', otp, { shouldValidate: true })}
CancelButton={
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
}
/>
);
}
return (
<Form {...form}>
<form
@@ -84,9 +108,13 @@ function DeleteAccountForm() {
action={deletePersonalAccountAction}
className={'flex flex-col space-y-4'}
>
<input type="hidden" name="otp" value={otp} />
<div className={'flex flex-col space-y-6'}>
<div
className={'border-destructive text-destructive border p-4 text-sm'}
className={
'border-destructive text-destructive rounded-md border p-4 text-sm'
}
>
<div className={'flex flex-col space-y-2'}>
<div>
@@ -98,25 +126,6 @@ function DeleteAccountForm() {
</div>
</div>
</div>
<FormItem>
<FormLabel>
<Trans i18nKey={'account:deleteProfileConfirmationInputLabel'} />
</FormLabel>
<FormControl>
<Input
autoComplete={'off'}
data-test={'delete-account-input-field'}
required
name={'confirmation'}
type={'text'}
className={'w-full'}
placeholder={''}
pattern={`DELETE`}
/>
</FormControl>
</FormItem>
</div>
<AlertDialogFooter>
@@ -124,21 +133,21 @@ function DeleteAccountForm() {
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
<DeleteAccountSubmitButton />
<DeleteAccountSubmitButton disabled={!form.formState.isValid} />
</AlertDialogFooter>
</form>
</Form>
);
}
function DeleteAccountSubmitButton() {
function DeleteAccountSubmitButton(props: { disabled: boolean }) {
const { pending } = useFormStatus();
return (
<Button
data-test={'confirm-delete-account-button'}
type={'submit'}
disabled={pending}
disabled={pending || props.disabled}
name={'action'}
variant={'destructive'}
>
@@ -151,6 +160,20 @@ function DeleteAccountSubmitButton() {
);
}
function DeleteAccountErrorContainer() {
return (
<div className="flex flex-col gap-y-4">
<DeleteAccountErrorAlert />
<div>
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
</div>
</div>
);
}
function DeleteAccountErrorAlert() {
return (
<Alert variant={'destructive'}>

View File

@@ -412,8 +412,15 @@ function FactorNameForm(
}
function QrImage({ src }: { src: string }) {
// eslint-disable-next-line @next/next/no-img-element
return <img alt={'QR Code'} src={src} width={160} height={160} className={'p-2 bg-white'} />;
return (
<img
alt={'QR Code'}
src={src}
width={160}
height={160}
className={'bg-white p-2'}
/>
);
}
function useEnrollFactor(userId: string) {

View File

@@ -1,5 +1,5 @@
import { z } from 'zod';
export const DeletePersonalAccountSchema = z.object({
confirmation: z.string().refine((value) => value === 'DELETE'),
otp: z.string().min(6),
});

View File

@@ -4,6 +4,7 @@ import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { enhanceAction } from '@kit/next/actions';
import { createOtpApi } from '@kit/otp';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
@@ -40,6 +41,12 @@ export const deletePersonalAccountAction = enhanceAction(
userId: user.id,
};
const otp = formData.get('otp') as string;
if (!otp) {
throw new Error('OTP is required');
}
if (!enableAccountDeletion) {
logger.warn(ctx, `Account deletion is not enabled`);
@@ -48,14 +55,33 @@ export const deletePersonalAccountAction = enhanceAction(
logger.info(ctx, `Deleting account...`);
// verify the OTP
const client = getSupabaseServerClient();
const otpApi = createOtpApi(client);
const otpResult = await otpApi.verifyToken({
token: otp,
userId: user.id,
purpose: 'delete-personal-account',
});
if (!otpResult.valid) {
throw new Error('Invalid OTP');
}
// validate the user ID matches the nonce's user ID
if (otpResult.user_id !== user.id) {
logger.error(
ctx,
`This token was meant to be used by a different user. Exiting.`,
);
throw new Error('Nonce mismatch');
}
// create a new instance of the personal accounts service
const service = createDeletePersonalAccountService();
// sign out the user before deleting their account
await client.auth.signOut();
// delete the user's account and cancel all subscriptions
await service.deletePersonalAccount({
adminClient: getSupabaseServerAdminClient(),
@@ -63,6 +89,9 @@ export const deletePersonalAccountAction = enhanceAction(
userEmail: user.email ?? null,
});
// sign out the user after deleting their account
await client.auth.signOut();
logger.info(ctx, `Account request successfully sent`);
// clear the cache for all pages

View File

@@ -47,6 +47,12 @@ class DeletePersonalAccountService {
// execute the deletion of the user
try {
await params.adminClient.auth.admin.deleteUser(userId);
logger.info(ctx, 'User successfully deleted!');
return {
success: true,
};
} catch (error) {
logger.error(
{
@@ -58,7 +64,5 @@ class DeletePersonalAccountService {
throw new Error('Error deleting user');
}
logger.info(ctx, 'User successfully deleted!');
}
}

View File

@@ -26,6 +26,7 @@
"@kit/mailers": "workspace:*",
"@kit/monitoring": "workspace:*",
"@kit/next": "workspace:*",
"@kit/otp": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",

View File

@@ -3,8 +3,10 @@
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useForm, useWatch } from 'react-hook-form';
import { VerifyOtpForm } from '@kit/otp/components';
import { useUser } from '@kit/supabase/hooks/use-user';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import {
AlertDialog,
@@ -16,17 +18,8 @@ import {
AlertDialogTitle,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Form } from '@kit/ui/form';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import { Trans } from '@kit/ui/trans';
import { TransferOwnershipConfirmationSchema } from '../../schema/transfer-ownership-confirmation.schema';
@@ -82,16 +75,44 @@ function TransferOrganizationOwnershipForm({
}) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<boolean>();
const { data: user } = useUser();
const form = useForm({
const form = useForm<{
accountId: string;
userId: string;
otp: string;
}>({
resolver: zodResolver(TransferOwnershipConfirmationSchema),
defaultValues: {
confirmation: '',
accountId,
userId,
otp: '',
},
});
const { otp } = useWatch({ control: form.control });
// If no OTP has been entered yet, show the OTP verification form
if (!otp) {
return (
<div className="flex flex-col space-y-6">
<VerifyOtpForm
purpose={`transfer-team-ownership-${accountId}`}
email={user?.email || ''}
onSuccess={(otpValue) => {
form.setValue('otp', otpValue, { shouldValidate: true });
}}
CancelButton={
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
}
data-test="verify-otp-form"
/>
</div>
);
}
return (
<Form {...form}>
<form
@@ -112,43 +133,19 @@ function TransferOrganizationOwnershipForm({
<TransferOwnershipErrorAlert />
</If>
<p>
<Trans
i18nKey={'teams:transferOwnershipDisclaimer'}
values={{
member: targetDisplayName,
}}
components={{ b: <b /> }}
/>
</p>
<div className="border-destructive rounded-md border p-4">
<p className="text-destructive text-sm">
<Trans
i18nKey={'teams:transferOwnershipDisclaimer'}
values={{
member: targetDisplayName,
}}
components={{ b: <b /> }}
/>
</p>
</div>
<FormField
name={'confirmation'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'teams:transferOwnershipInputLabel'} />
</FormLabel>
<FormControl>
<Input
autoComplete={'off'}
type={'text'}
required
{...field}
/>
</FormControl>
<FormDescription>
<Trans i18nKey={'teams:transferOwnershipInputDescription'} />
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<input type="hidden" name="otp" value={otp} />
<div>
<p className={'text-muted-foreground'}>

View File

@@ -3,10 +3,11 @@
import { useFormStatus } from 'react-dom';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
import { ErrorBoundary } from '@kit/monitoring/components';
import { VerifyOtpForm } from '@kit/otp/components';
import { useUser } from '@kit/supabase/hooks/use-user';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import {
@@ -61,8 +62,12 @@ export function TeamAccountDangerZone({
// Only the primary owner can delete the team account
const userIsPrimaryOwner = user.id === primaryOwnerUserId;
if (userIsPrimaryOwner && features.enableTeamDeletion) {
return <DeleteTeamContainer account={account} />;
if (userIsPrimaryOwner) {
if (features.enableTeamDeletion) {
return <DeleteTeamContainer account={account} />;
}
return;
}
// A primary owner can't leave the team account
@@ -79,7 +84,7 @@ function DeleteTeamContainer(props: {
return (
<div className={'flex flex-col space-y-4'}>
<div className={'flex flex-col space-y-1'}>
<span className={'font-medium'}>
<span className={'text-sm font-medium'}>
<Trans i18nKey={'teams:deleteTeam'} />
</span>
@@ -139,22 +144,42 @@ function DeleteTeamConfirmationForm({
name: string;
id: string;
}) {
const { data: user } = useUser();
const form = useForm({
mode: 'onChange',
reValidateMode: 'onChange',
resolver: zodResolver(
z.object({
name: z.string().refine((value) => value === name, {
message: 'Name does not match',
path: ['name'],
}),
otp: z.string().min(6).max(6),
}),
),
defaultValues: {
name: ''
otp: '',
},
});
const { otp } = useWatch({ control: form.control });
if (!user?.email) {
return <LoadingOverlay fullPage={false} />;
}
if (!otp) {
return (
<VerifyOtpForm
purpose={`delete-team-account-${id}`}
email={user.email}
onSuccess={(otp) => form.setValue('otp', otp, { shouldValidate: true })}
CancelButton={
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
}
/>
);
}
return (
<ErrorBoundary fallback={<DeleteTeamErrorAlert />}>
<Form {...form}>
@@ -166,8 +191,7 @@ function DeleteTeamConfirmationForm({
<div className={'flex flex-col space-y-2'}>
<div
className={
'border-2 border-red-500 p-4 text-sm text-red-500' +
' my-4 flex flex-col space-y-2'
'border-destructive text-destructive my-4 flex flex-col space-y-2 rounded-md border-2 p-4 text-sm'
}
>
<div>
@@ -185,36 +209,7 @@ function DeleteTeamConfirmationForm({
</div>
<input type="hidden" value={id} name={'accountId'} />
<FormField
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'teams:teamNameInputLabel'} />
</FormLabel>
<FormControl>
<Input
data-test={'delete-team-form-confirm-input'}
required
type={'text'}
autoComplete={'off'}
className={'w-full'}
placeholder={''}
pattern={name}
{...field}
/>
</FormControl>
<FormDescription>
<Trans i18nKey={'teams:deleteTeamInputField'} />
</FormDescription>
<FormMessage />
</FormItem>
)}
name={'confirm'}
/>
<input type="hidden" value={otp} name={'otp'} />
</div>
<AlertDialogFooter>
@@ -260,7 +255,7 @@ function LeaveTeamContainer(props: {
}),
),
defaultValues: {
confirmation: '' as 'LEAVE'
confirmation: '' as 'LEAVE',
},
});
@@ -375,7 +370,7 @@ function LeaveTeamSubmitButton() {
function LeaveTeamErrorAlert() {
return (
<>
<div className={'flex flex-col space-y-4'}>
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'teams:leaveTeamErrorHeading'} />
@@ -391,20 +386,28 @@ function LeaveTeamErrorAlert() {
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
</AlertDialogFooter>
</>
</div>
);
}
function DeleteTeamErrorAlert() {
return (
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'teams:deleteTeamErrorHeading'} />
</AlertTitle>
<div className={'flex flex-col space-y-4'}>
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'teams:deleteTeamErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'common:genericError'} />
</AlertDescription>
</Alert>
<AlertDescription>
<Trans i18nKey={'common:genericError'} />
</AlertDescription>
</Alert>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
</AlertDialogFooter>
</div>
);
}

View File

@@ -2,4 +2,5 @@ import { z } from 'zod';
export const DeleteTeamAccountSchema = z.object({
accountId: z.string().uuid(),
otp: z.string().min(1),
});

View File

@@ -1,9 +1,11 @@
import { z } from 'zod';
const confirmationString = 'TRANSFER';
export const TransferOwnershipConfirmationSchema = z.object({
userId: z.string().uuid(),
confirmation: z.custom((value) => value === confirmationString),
accountId: z.string().uuid(),
userId: z.string().uuid(),
otp: z.string().min(6),
});
export type TransferOwnershipConfirmationData = z.infer<
typeof TransferOwnershipConfirmationSchema
>;

View File

@@ -5,6 +5,7 @@ import { redirect } from 'next/navigation';
import type { SupabaseClient } from '@supabase/supabase-js';
import { enhanceAction } from '@kit/next/actions';
import { createOtpApi } from '@kit/otp';
import { getLogger } from '@kit/shared/logger';
import type { Database } from '@kit/supabase/database';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
@@ -23,6 +24,18 @@ export const deleteTeamAccountAction = enhanceAction(
Object.fromEntries(formData.entries()),
);
const otpService = createOtpApi(getSupabaseServerClient());
const otpResult = await otpService.verifyToken({
purpose: `delete-team-account-${params.accountId}`,
userId: user.id,
token: params.otp,
});
if (!otpResult.valid) {
throw new Error('Invalid OTP code');
}
const ctx = {
name: 'team-accounts.delete',
userId: user.id,
@@ -59,7 +72,7 @@ async function deleteTeamAccount(params: {
const service = createDeleteTeamAccountService();
// verify that the user has the necessary permissions to delete the team account
await assertUserPermissionsToDeleteTeamAccount(client, params);
await assertUserPermissionsToDeleteTeamAccount(client, params.accountId);
// delete the team account
await service.deleteTeamAccount(client, params);
@@ -67,20 +80,17 @@ async function deleteTeamAccount(params: {
async function assertUserPermissionsToDeleteTeamAccount(
client: SupabaseClient<Database>,
params: {
accountId: string;
userId: string;
},
accountId: string,
) {
const { data, error } = await client
.from('accounts')
.select('id')
.eq('primary_owner_user_id', params.userId)
.eq('is_personal_account', false)
.eq('id', params.accountId)
const { data: isOwner, error } = await client
.rpc('is_account_owner', {
account_id: accountId,
})
.single();
if (error ?? !data) {
throw new Error('Account not found');
if (error || !isOwner) {
throw new Error('You do not have permission to delete this account');
}
return isOwner;
}

View File

@@ -3,6 +3,8 @@
import { revalidatePath } from 'next/cache';
import { enhanceAction } from '@kit/next/actions';
import { createOtpApi } from '@kit/otp';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
@@ -61,25 +63,66 @@ export const updateMemberRoleAction = enhanceAction(
/**
* @name transferOwnershipAction
* @description Transfers the ownership of an account to another member.
* Requires OTP verification for security.
*/
export const transferOwnershipAction = enhanceAction(
async (data) => {
async (data, user) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const ctx = {
name: 'teams.transferOwnership',
userId: user.id,
accountId: data.accountId,
};
logger.info(ctx, 'Processing team ownership transfer request...');
// assert that the user is the owner of the account
const { data: isOwner, error } = await client.rpc('is_account_owner', {
account_id: data.accountId,
});
if (error ?? !isOwner) {
if (error || !isOwner) {
logger.error(ctx, 'User is not the owner of this account');
throw new Error(
`You must be the owner of the account to transfer ownership`,
);
}
// Verify the OTP
const otpApi = createOtpApi(client);
const otpResult = await otpApi.verifyToken({
token: data.otp,
userId: user.id,
purpose: `transfer-team-ownership-${data.accountId}`,
});
if (!otpResult.valid) {
logger.error(ctx, 'Invalid OTP provided');
throw new Error('Invalid OTP');
}
// validate the user ID matches the nonce's user ID
if (otpResult.user_id !== user.id) {
logger.error(
ctx,
`This token was meant to be used by a different user. Exiting.`,
);
throw new Error('Nonce mismatch');
}
logger.info(
ctx,
'OTP verification successful. Proceeding with ownership transfer...',
);
const service = createAccountMembersService(client);
// at this point, the user is authenticated and is the owner of the account
// at this point, the user is authenticated, is the owner of the account, and has verified via OTP
// so we proceed with the transfer of ownership with admin privileges
const adminClient = getSupabaseServerAdminClient();
@@ -89,6 +132,8 @@ export const transferOwnershipAction = enhanceAction(
// revalidate all pages that depend on the account
revalidatePath('/home/[account]', 'layout');
logger.info(ctx, 'Team ownership transferred successfully');
return {
success: true,
};