Add captcha support to authentication features

The update includes the implementation of captcha support during the sign-in and sign-up process for user accounts. The process ensures a better level of security against bot-based attacks. Also, the code has been refactored to separate error and success alerts and unnecessary useEffect hooks have been removed. Moreover, some logic concerning the authentication rendering has been simplified.
This commit is contained in:
giancarlo
2024-04-07 10:56:33 +08:00
parent 36bcf64f52
commit 0a9c1f35c6
16 changed files with 209 additions and 78 deletions

View File

@@ -10,16 +10,21 @@ import { Database } from '@kit/supabase/database';
import { requireUser } from '@kit/supabase/require-user';
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
import { TeamCheckoutSchema } from '~/(dashboard)/home/[account]/_lib/schema/team-checkout.schema';
import appConfig from '~/config/app.config';
import billingConfig from '~/config/billing.config';
import pathsConfig from '~/config/paths.config';
import { TeamCheckoutSchema } from '../../_lib/schema/team-checkout.schema';
export class TeamBillingService {
private readonly namespace = 'billing.team-account';
constructor(private readonly client: SupabaseClient<Database>) {}
/**
* @name createCheckout
* @description Creates a checkout session for a Team account
*/
async createCheckout(params: z.infer<typeof TeamCheckoutSchema>) {
// we require the user to be authenticated
const { data: user } = await requireUser(this.client);
@@ -126,6 +131,12 @@ export class TeamBillingService {
}
}
/**
* @name createBillingPortalSession
* @description Creates a new billing portal session for a team account
* @param accountId
* @param slug
*/
async createBillingPortalSession({
accountId,
slug,

View File

@@ -4,13 +4,16 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryStreamedHydration } from '@tanstack/react-query-next-experimental';
import { ThemeProvider } from 'next-themes';
import { CaptchaProvider, CaptchaTokenSetter } from '@kit/auth/captcha';
import { I18nProvider } from '@kit/i18n/provider';
import { AuthChangeListener } from '@kit/supabase/components/auth-change-listener';
import appConfig from '~/config/app.config';
import authConfig from '~/config/auth.config';
import pathsConfig from '~/config/paths.config';
import { i18nResolver } from '~/lib/i18n/i18n.resolver';
const captchaSiteKey = authConfig.captchaTokenSiteKey;
const queryClient = new QueryClient();
export function RootProviders({
@@ -22,18 +25,22 @@ export function RootProviders({
return (
<QueryClientProvider client={queryClient}>
<ReactQueryStreamedHydration>
<AuthChangeListener appHomePath={pathsConfig.app.home}>
<I18nProvider lang={lang} resolver={i18nResolver}>
<ThemeProvider
attribute="class"
enableSystem
disableTransitionOnChange
defaultTheme={appConfig.theme}
>
{children}
</ThemeProvider>
</I18nProvider>
</AuthChangeListener>
<CaptchaProvider>
<CaptchaTokenSetter siteKey={captchaSiteKey} />
<AuthChangeListener appHomePath={pathsConfig.app.home}>
<I18nProvider lang={lang} resolver={i18nResolver}>
<ThemeProvider
attribute="class"
enableSystem
disableTransitionOnChange
defaultTheme={appConfig.theme}
>
{children}
</ThemeProvider>
</I18nProvider>
</AuthChangeListener>
</CaptchaProvider>
</ReactQueryStreamedHydration>
</QueryClientProvider>
);

View File

@@ -5,6 +5,7 @@ import { z } from 'zod';
const providers: z.ZodType<Provider> = getProviders();
const AuthConfigSchema = z.object({
captchaTokenSiteKey: z.string().min(1).optional(),
providers: z.object({
password: z.boolean({
description: 'Enable password authentication.',
@@ -17,6 +18,10 @@ const AuthConfigSchema = z.object({
});
const authConfig = AuthConfigSchema.parse({
// NB: This is a public key, so it's safe to expose.
// Copy the value from the Supabase Dashboard.
captchaTokenSiteKey: process.env.NEXT_PUBLIC_CAPTCHA_SITE_KEY ?? '',
// NB: Enable the providers below in the Supabase Console
// in your production project
providers: {

View File

@@ -32,6 +32,7 @@
"@kit/supabase": "workspace:^",
"@kit/team-accounts": "workspace:^",
"@kit/ui": "workspace:^",
"@marsidev/react-turnstile": "^0.5.4",
"@radix-ui/react-icons": "^1.3.0",
"@supabase/ssr": "^0.1.0",
"@supabase/supabase-js": "^2.42.0",

View File

@@ -63,6 +63,8 @@
"sendingEmailCode": "Sending code...",
"resetPasswordError": "Sorry, we could not reset your password. Please try again.",
"emailPlaceholder": "your@email.com",
"inviteAlertHeading": "You have been invited to join a team",
"inviteAlertBody": "You have been invited to join a team. Please sign in or sign up to accept the invite.",
"errors": {
"Invalid login credentials": "The credentials entered are invalid",
"User already registered": "This credential is already in use. Please try with another one.",

View File

@@ -67,6 +67,8 @@ export async function createLemonSqueezyCheckout(
},
productOptions: {
redirectUrl: params.returnUrl,
// only show the selected variant ID
enabledVariants: [variantId],
},
expiresAt: null,
preview: true,

View File

@@ -13,7 +13,8 @@
"./sign-up": "./src/sign-up.ts",
"./password-reset": "./src/password-reset.ts",
"./shared": "./src/shared.ts",
"./mfa": "./src/mfa.ts"
"./mfa": "./src/mfa.ts",
"./captcha": "./src/components/captcha/index.ts"
},
"devDependencies": {
"@hookform/resolvers": "^3.3.4",
@@ -24,6 +25,7 @@
"@kit/tailwind-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@marsidev/react-turnstile": "^0.5.4",
"@radix-ui/react-icons": "^1.3.0",
"@tanstack/react-query": "5.28.6",
"react-i18next": "^14.1.0",

View File

@@ -0,0 +1,24 @@
'use client';
import { createContext, useState } from 'react';
export const Captcha = createContext<{
token: string;
setToken: (token: string) => void;
}>({
token: '',
setToken: (_: string) => {
// do nothing
return '';
},
});
export function CaptchaProvider(props: { children: React.ReactNode }) {
const [token, setToken] = useState<string>('');
return (
<Captcha.Provider value={{ token, setToken }}>
{props.children}
</Captcha.Provider>
);
}

View File

@@ -0,0 +1,17 @@
'use client';
import { useContext } from 'react';
import { Turnstile } from '@marsidev/react-turnstile';
import { Captcha } from './captcha-provider';
export function CaptchaTokenSetter(props: { siteKey: string | undefined }) {
const { setToken } = useContext(Captcha);
if (!props.siteKey) {
return null;
}
return <Turnstile siteKey={props.siteKey} onSuccess={setToken} />;
}

View File

@@ -0,0 +1,3 @@
export * from './captchaTokenSetter';
export * from './use-captcha-token';
export * from './captcha-provider';

View File

@@ -0,0 +1,13 @@
import { useContext } from 'react';
import { Captcha } from './captcha-provider';
export function useCaptchaToken() {
const context = useContext(Captcha);
if (!context) {
throw new Error(`useCaptchaToken must be used within a CaptchaProvider`);
}
return context.token;
}

View File

@@ -1,7 +1,7 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { CheckIcon } from '@radix-ui/react-icons';
import { CheckIcon, ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
@@ -25,8 +25,10 @@ import { Trans } from '@kit/ui/trans';
export function MagicLinkAuthContainer({
inviteToken,
redirectUrl,
captchaToken,
}: {
inviteToken?: string;
captchaToken?: string;
redirectUrl: string;
}) {
const { t } = useTranslation();
@@ -52,6 +54,7 @@ export function MagicLinkAuthContainer({
email,
options: {
emailRedirectTo,
captchaToken,
},
});
@@ -63,34 +66,14 @@ export function MagicLinkAuthContainer({
};
if (signInWithOtpMutation.data) {
return (
<Alert variant={'success'}>
<CheckIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'auth:sendLinkSuccess'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'auth:sendLinkSuccessDescription'} />
</AlertDescription>
</Alert>
);
return <SuccessAlert />;
}
return (
<Form {...form}>
<form className={'w-full'} onSubmit={form.handleSubmit(onSubmit)}>
<If condition={signInWithOtpMutation.error}>
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'auth:errors.generic'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'auth:errors.link'} />
</AlertDescription>
</Alert>
<ErrorAlert />
</If>
<div className={'flex flex-col space-y-4'}>
@@ -130,3 +113,35 @@ export function MagicLinkAuthContainer({
</Form>
);
}
function SuccessAlert() {
return (
<Alert variant={'success'}>
<CheckIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'auth:sendLinkSuccess'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'auth:sendLinkSuccessDescription'} />
</AlertDescription>
</Alert>
);
}
function ErrorAlert() {
return (
<Alert variant={'destructive'}>
<ExclamationTriangleIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'auth:errors.generic'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'auth:errors.link'} />
</AlertDescription>
</Alert>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import { Check } from 'lucide-react';
@@ -12,30 +12,22 @@ import { Trans } from '@kit/ui/trans';
import { AuthErrorAlert } from './auth-error-alert';
import { PasswordSignUpForm } from './password-sign-up-form';
interface EmailPasswordSignUpContainerProps {
onSignUp?: (userId?: string) => unknown;
emailRedirectTo: string;
captchaToken?: string;
}
export function EmailPasswordSignUpContainer({
onSignUp,
onError,
emailRedirectTo,
}: React.PropsWithChildren<{
onSignUp?: (userId?: string) => unknown;
onError?: (error?: unknown) => unknown;
emailRedirectTo: string;
}>) {
captchaToken,
}: EmailPasswordSignUpContainerProps) {
const signUpMutation = useSignUpWithEmailAndPassword();
const redirecting = useRef(false);
const loading = signUpMutation.isPending || redirecting.current;
const [showVerifyEmailAlert, setShowVerifyEmailAlert] = useState(false);
const callOnErrorCallback = useCallback(() => {
if (signUpMutation.error && onError) {
onError(signUpMutation.error);
}
}, [signUpMutation.error, onError]);
useEffect(() => {
callOnErrorCallback();
}, [callOnErrorCallback]);
const onSignupRequested = useCallback(
async (credentials: { email: string; password: string }) => {
if (loading) {
@@ -46,6 +38,7 @@ export function EmailPasswordSignUpContainer({
const data = await signUpMutation.mutateAsync({
...credentials,
emailRedirectTo,
captchaToken,
});
setShowVerifyEmailAlert(true);
@@ -54,28 +47,16 @@ export function EmailPasswordSignUpContainer({
onSignUp(data.user?.id);
}
} catch (error) {
if (onError) {
onError(error);
}
console.error(error);
}
},
[emailRedirectTo, loading, onError, onSignUp, signUpMutation],
[emailRedirectTo, loading, onSignUp, signUpMutation],
);
return (
<>
<If condition={showVerifyEmailAlert}>
<Alert variant={'success'}>
<Check className={'w-4'} />
<AlertTitle>
<Trans i18nKey={'auth:emailConfirmationAlertHeading'} />
</AlertTitle>
<AlertDescription data-test={'email-confirmation-alert'}>
<Trans i18nKey={'auth:emailConfirmationAlertBody'} />
</AlertDescription>
</Alert>
<SuccessAlert />
</If>
<If condition={!showVerifyEmailAlert}>
@@ -86,3 +67,19 @@ export function EmailPasswordSignUpContainer({
</>
);
}
function SuccessAlert() {
return (
<Alert variant={'success'}>
<Check className={'w-4'} />
<AlertTitle>
<Trans i18nKey={'auth:emailConfirmationAlertHeading'} />
</AlertTitle>
<AlertDescription data-test={'email-confirmation-alert'}>
<Trans i18nKey={'auth:emailConfirmationAlertBody'} />
</AlertDescription>
</Alert>
);
}

View File

@@ -6,7 +6,9 @@ import { isBrowser } from '@kit/shared/utils';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { If } from '@kit/ui/if';
import { Separator } from '@kit/ui/separator';
import { Trans } from '@kit/ui/trans';
import { useCaptchaToken } from './captcha';
import { MagicLinkAuthContainer } from './magic-link-auth-container';
import { OauthProviders } from './oauth-providers';
import { EmailPasswordSignUpContainer } from './password-sign-up-container';
@@ -26,27 +28,26 @@ export function SignUpMethodsContainer(props: {
inviteToken?: string;
}) {
const redirectUrl = getCallbackUrl(props);
const captchaToken = useCaptchaToken();
return (
<>
<If condition={props.inviteToken}>
<Alert variant={'info'}>
<AlertTitle>You have been invited to join a team</AlertTitle>
<AlertDescription>
Please sign up to continue with the invitation and create your
account.
</AlertDescription>
</Alert>
<InviteAlert />
</If>
<If condition={props.providers.password}>
<EmailPasswordSignUpContainer emailRedirectTo={redirectUrl} />
<EmailPasswordSignUpContainer
captchaToken={captchaToken}
emailRedirectTo={redirectUrl}
/>
</If>
<If condition={props.providers.magicLink}>
<MagicLinkAuthContainer
inviteToken={props.inviteToken}
redirectUrl={redirectUrl}
captchaToken={captchaToken}
/>
</If>
@@ -88,3 +89,17 @@ function getCallbackUrl(props: {
return url.href;
}
function InviteAlert() {
return (
<Alert variant={'info'}>
<AlertTitle>
<Trans i18nKey={'auth:inviteAlertHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'auth:inviteAlertBody'} />
</AlertDescription>
</Alert>
);
}

View File

@@ -6,6 +6,7 @@ interface Credentials {
email: string;
password: string;
emailRedirectTo: string;
captchaToken?: string;
}
export function useSignUpWithEmailAndPassword() {
@@ -13,12 +14,13 @@ export function useSignUpWithEmailAndPassword() {
const mutationKey = ['auth', 'sign-up-with-email-password'];
const mutationFn = async (params: Credentials) => {
const { emailRedirectTo, ...credentials } = params;
const { emailRedirectTo, captchaToken, ...credentials } = params;
const response = await client.auth.signUp({
...credentials,
options: {
emailRedirectTo,
captchaToken,
},
});

15
pnpm-lock.yaml generated
View File

@@ -83,6 +83,9 @@ importers:
'@kit/ui':
specifier: workspace:^
version: link:../../packages/ui
'@marsidev/react-turnstile':
specifier: ^0.5.4
version: 0.5.4(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-icons':
specifier: ^1.3.0
version: 1.3.0(react@18.2.0)
@@ -574,6 +577,9 @@ importers:
'@kit/ui':
specifier: workspace:*
version: link:../../ui
'@marsidev/react-turnstile':
specifier: ^0.5.4
version: 0.5.4(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-icons':
specifier: ^1.3.0
version: 1.3.0(react@18.2.0)
@@ -2209,6 +2215,15 @@ packages:
read-yaml-file: 1.1.0
dev: false
/@marsidev/react-turnstile@0.5.4(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-cloUDkEcJm+G7p3J8DwPtRNNB2GZqVi/nlIbgu9o3VzNyV5K/bWcSfOyWouRiR3umAQZmsFpR3OFYa4mCmy4AQ==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
dependencies:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
/@mdx-js/esbuild@2.3.0(esbuild@0.20.2):
resolution: {integrity: sha512-r/vsqsM0E+U4Wr0DK+0EfmABE/eg+8ITW4DjvYdh3ve/tK2safaqHArNnaqbOk1DjYGrhxtoXoGaM3BY8fGBTA==}
peerDependencies: