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 { requireUser } from '@kit/supabase/require-user';
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client'; 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 appConfig from '~/config/app.config';
import billingConfig from '~/config/billing.config'; import billingConfig from '~/config/billing.config';
import pathsConfig from '~/config/paths.config'; import pathsConfig from '~/config/paths.config';
import { TeamCheckoutSchema } from '../../_lib/schema/team-checkout.schema';
export class TeamBillingService { export class TeamBillingService {
private readonly namespace = 'billing.team-account'; private readonly namespace = 'billing.team-account';
constructor(private readonly client: SupabaseClient<Database>) {} constructor(private readonly client: SupabaseClient<Database>) {}
/**
* @name createCheckout
* @description Creates a checkout session for a Team account
*/
async createCheckout(params: z.infer<typeof TeamCheckoutSchema>) { async createCheckout(params: z.infer<typeof TeamCheckoutSchema>) {
// we require the user to be authenticated // we require the user to be authenticated
const { data: user } = await requireUser(this.client); 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({ async createBillingPortalSession({
accountId, accountId,
slug, slug,

View File

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

View File

@@ -5,6 +5,7 @@ import { z } from 'zod';
const providers: z.ZodType<Provider> = getProviders(); const providers: z.ZodType<Provider> = getProviders();
const AuthConfigSchema = z.object({ const AuthConfigSchema = z.object({
captchaTokenSiteKey: z.string().min(1).optional(),
providers: z.object({ providers: z.object({
password: z.boolean({ password: z.boolean({
description: 'Enable password authentication.', description: 'Enable password authentication.',
@@ -17,6 +18,10 @@ const AuthConfigSchema = z.object({
}); });
const authConfig = AuthConfigSchema.parse({ 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 // NB: Enable the providers below in the Supabase Console
// in your production project // in your production project
providers: { providers: {

View File

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

View File

@@ -63,6 +63,8 @@
"sendingEmailCode": "Sending code...", "sendingEmailCode": "Sending code...",
"resetPasswordError": "Sorry, we could not reset your password. Please try again.", "resetPasswordError": "Sorry, we could not reset your password. Please try again.",
"emailPlaceholder": "your@email.com", "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": { "errors": {
"Invalid login credentials": "The credentials entered are invalid", "Invalid login credentials": "The credentials entered are invalid",
"User already registered": "This credential is already in use. Please try with another one.", "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: { productOptions: {
redirectUrl: params.returnUrl, redirectUrl: params.returnUrl,
// only show the selected variant ID
enabledVariants: [variantId],
}, },
expiresAt: null, expiresAt: null,
preview: true, preview: true,

View File

@@ -13,7 +13,8 @@
"./sign-up": "./src/sign-up.ts", "./sign-up": "./src/sign-up.ts",
"./password-reset": "./src/password-reset.ts", "./password-reset": "./src/password-reset.ts",
"./shared": "./src/shared.ts", "./shared": "./src/shared.ts",
"./mfa": "./src/mfa.ts" "./mfa": "./src/mfa.ts",
"./captcha": "./src/components/captcha/index.ts"
}, },
"devDependencies": { "devDependencies": {
"@hookform/resolvers": "^3.3.4", "@hookform/resolvers": "^3.3.4",
@@ -24,6 +25,7 @@
"@kit/tailwind-config": "workspace:*", "@kit/tailwind-config": "workspace:*",
"@kit/tsconfig": "workspace:*", "@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*", "@kit/ui": "workspace:*",
"@marsidev/react-turnstile": "^0.5.4",
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
"@tanstack/react-query": "5.28.6", "@tanstack/react-query": "5.28.6",
"react-i18next": "^14.1.0", "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'; 'use client';
import { zodResolver } from '@hookform/resolvers/zod'; 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 { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -25,8 +25,10 @@ import { Trans } from '@kit/ui/trans';
export function MagicLinkAuthContainer({ export function MagicLinkAuthContainer({
inviteToken, inviteToken,
redirectUrl, redirectUrl,
captchaToken,
}: { }: {
inviteToken?: string; inviteToken?: string;
captchaToken?: string;
redirectUrl: string; redirectUrl: string;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -52,6 +54,7 @@ export function MagicLinkAuthContainer({
email, email,
options: { options: {
emailRedirectTo, emailRedirectTo,
captchaToken,
}, },
}); });
@@ -63,34 +66,14 @@ export function MagicLinkAuthContainer({
}; };
if (signInWithOtpMutation.data) { if (signInWithOtpMutation.data) {
return ( return <SuccessAlert />;
<Alert variant={'success'}>
<CheckIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'auth:sendLinkSuccess'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'auth:sendLinkSuccessDescription'} />
</AlertDescription>
</Alert>
);
} }
return ( return (
<Form {...form}> <Form {...form}>
<form className={'w-full'} onSubmit={form.handleSubmit(onSubmit)}> <form className={'w-full'} onSubmit={form.handleSubmit(onSubmit)}>
<If condition={signInWithOtpMutation.error}> <If condition={signInWithOtpMutation.error}>
<Alert variant={'destructive'}> <ErrorAlert />
<AlertTitle>
<Trans i18nKey={'auth:errors.generic'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'auth:errors.link'} />
</AlertDescription>
</Alert>
</If> </If>
<div className={'flex flex-col space-y-4'}> <div className={'flex flex-col space-y-4'}>
@@ -130,3 +113,35 @@ export function MagicLinkAuthContainer({
</Form> </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'; 'use client';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useRef, useState } from 'react';
import { Check } from 'lucide-react'; import { Check } from 'lucide-react';
@@ -12,30 +12,22 @@ import { Trans } from '@kit/ui/trans';
import { AuthErrorAlert } from './auth-error-alert'; import { AuthErrorAlert } from './auth-error-alert';
import { PasswordSignUpForm } from './password-sign-up-form'; import { PasswordSignUpForm } from './password-sign-up-form';
interface EmailPasswordSignUpContainerProps {
onSignUp?: (userId?: string) => unknown;
emailRedirectTo: string;
captchaToken?: string;
}
export function EmailPasswordSignUpContainer({ export function EmailPasswordSignUpContainer({
onSignUp, onSignUp,
onError,
emailRedirectTo, emailRedirectTo,
}: React.PropsWithChildren<{ captchaToken,
onSignUp?: (userId?: string) => unknown; }: EmailPasswordSignUpContainerProps) {
onError?: (error?: unknown) => unknown;
emailRedirectTo: string;
}>) {
const signUpMutation = useSignUpWithEmailAndPassword(); const signUpMutation = useSignUpWithEmailAndPassword();
const redirecting = useRef(false); const redirecting = useRef(false);
const loading = signUpMutation.isPending || redirecting.current; const loading = signUpMutation.isPending || redirecting.current;
const [showVerifyEmailAlert, setShowVerifyEmailAlert] = useState(false); const [showVerifyEmailAlert, setShowVerifyEmailAlert] = useState(false);
const callOnErrorCallback = useCallback(() => {
if (signUpMutation.error && onError) {
onError(signUpMutation.error);
}
}, [signUpMutation.error, onError]);
useEffect(() => {
callOnErrorCallback();
}, [callOnErrorCallback]);
const onSignupRequested = useCallback( const onSignupRequested = useCallback(
async (credentials: { email: string; password: string }) => { async (credentials: { email: string; password: string }) => {
if (loading) { if (loading) {
@@ -46,6 +38,7 @@ export function EmailPasswordSignUpContainer({
const data = await signUpMutation.mutateAsync({ const data = await signUpMutation.mutateAsync({
...credentials, ...credentials,
emailRedirectTo, emailRedirectTo,
captchaToken,
}); });
setShowVerifyEmailAlert(true); setShowVerifyEmailAlert(true);
@@ -54,28 +47,16 @@ export function EmailPasswordSignUpContainer({
onSignUp(data.user?.id); onSignUp(data.user?.id);
} }
} catch (error) { } catch (error) {
if (onError) { console.error(error);
onError(error);
}
} }
}, },
[emailRedirectTo, loading, onError, onSignUp, signUpMutation], [emailRedirectTo, loading, onSignUp, signUpMutation],
); );
return ( return (
<> <>
<If condition={showVerifyEmailAlert}> <If condition={showVerifyEmailAlert}>
<Alert variant={'success'}> <SuccessAlert />
<Check className={'w-4'} />
<AlertTitle>
<Trans i18nKey={'auth:emailConfirmationAlertHeading'} />
</AlertTitle>
<AlertDescription data-test={'email-confirmation-alert'}>
<Trans i18nKey={'auth:emailConfirmationAlertBody'} />
</AlertDescription>
</Alert>
</If> </If>
<If condition={!showVerifyEmailAlert}> <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 { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { If } from '@kit/ui/if'; import { If } from '@kit/ui/if';
import { Separator } from '@kit/ui/separator'; import { Separator } from '@kit/ui/separator';
import { Trans } from '@kit/ui/trans';
import { useCaptchaToken } from './captcha';
import { MagicLinkAuthContainer } from './magic-link-auth-container'; import { MagicLinkAuthContainer } from './magic-link-auth-container';
import { OauthProviders } from './oauth-providers'; import { OauthProviders } from './oauth-providers';
import { EmailPasswordSignUpContainer } from './password-sign-up-container'; import { EmailPasswordSignUpContainer } from './password-sign-up-container';
@@ -26,27 +28,26 @@ export function SignUpMethodsContainer(props: {
inviteToken?: string; inviteToken?: string;
}) { }) {
const redirectUrl = getCallbackUrl(props); const redirectUrl = getCallbackUrl(props);
const captchaToken = useCaptchaToken();
return ( return (
<> <>
<If condition={props.inviteToken}> <If condition={props.inviteToken}>
<Alert variant={'info'}> <InviteAlert />
<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>
</If> </If>
<If condition={props.providers.password}> <If condition={props.providers.password}>
<EmailPasswordSignUpContainer emailRedirectTo={redirectUrl} /> <EmailPasswordSignUpContainer
captchaToken={captchaToken}
emailRedirectTo={redirectUrl}
/>
</If> </If>
<If condition={props.providers.magicLink}> <If condition={props.providers.magicLink}>
<MagicLinkAuthContainer <MagicLinkAuthContainer
inviteToken={props.inviteToken} inviteToken={props.inviteToken}
redirectUrl={redirectUrl} redirectUrl={redirectUrl}
captchaToken={captchaToken}
/> />
</If> </If>
@@ -88,3 +89,17 @@ function getCallbackUrl(props: {
return url.href; 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; email: string;
password: string; password: string;
emailRedirectTo: string; emailRedirectTo: string;
captchaToken?: string;
} }
export function useSignUpWithEmailAndPassword() { export function useSignUpWithEmailAndPassword() {
@@ -13,12 +14,13 @@ export function useSignUpWithEmailAndPassword() {
const mutationKey = ['auth', 'sign-up-with-email-password']; const mutationKey = ['auth', 'sign-up-with-email-password'];
const mutationFn = async (params: Credentials) => { const mutationFn = async (params: Credentials) => {
const { emailRedirectTo, ...credentials } = params; const { emailRedirectTo, captchaToken, ...credentials } = params;
const response = await client.auth.signUp({ const response = await client.auth.signUp({
...credentials, ...credentials,
options: { options: {
emailRedirectTo, emailRedirectTo,
captchaToken,
}, },
}); });

15
pnpm-lock.yaml generated
View File

@@ -83,6 +83,9 @@ importers:
'@kit/ui': '@kit/ui':
specifier: workspace:^ specifier: workspace:^
version: link:../../packages/ui 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': '@radix-ui/react-icons':
specifier: ^1.3.0 specifier: ^1.3.0
version: 1.3.0(react@18.2.0) version: 1.3.0(react@18.2.0)
@@ -574,6 +577,9 @@ importers:
'@kit/ui': '@kit/ui':
specifier: workspace:* specifier: workspace:*
version: link:../../ui 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': '@radix-ui/react-icons':
specifier: ^1.3.0 specifier: ^1.3.0
version: 1.3.0(react@18.2.0) version: 1.3.0(react@18.2.0)
@@ -2209,6 +2215,15 @@ packages:
read-yaml-file: 1.1.0 read-yaml-file: 1.1.0
dev: false 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): /@mdx-js/esbuild@2.3.0(esbuild@0.20.2):
resolution: {integrity: sha512-r/vsqsM0E+U4Wr0DK+0EfmABE/eg+8ITW4DjvYdh3ve/tK2safaqHArNnaqbOk1DjYGrhxtoXoGaM3BY8fGBTA==} resolution: {integrity: sha512-r/vsqsM0E+U4Wr0DK+0EfmABE/eg+8ITW4DjvYdh3ve/tK2safaqHArNnaqbOk1DjYGrhxtoXoGaM3BY8fGBTA==}
peerDependencies: peerDependencies: