From 0a9c1f35c62338c376343a4c46c67000d77283f6 Mon Sep 17 00:00:00 2001 From: giancarlo Date: Sun, 7 Apr 2024 10:56:33 +0800 Subject: [PATCH] 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. --- .../_lib/server/team-billing.service.ts | 13 +++- apps/web/components/root-providers.tsx | 31 ++++++---- apps/web/config/auth.config.ts | 5 ++ apps/web/package.json | 1 + apps/web/public/locales/en/auth.json | 2 + .../services/create-lemon-squeezy-checkout.ts | 2 + packages/features/auth/package.json | 4 +- .../components/captcha/captcha-provider.tsx | 24 ++++++++ .../components/captcha/captchaTokenSetter.tsx | 17 ++++++ .../auth/src/components/captcha/index.ts | 3 + .../components/captcha/use-captcha-token.ts | 13 ++++ .../components/magic-link-auth-container.tsx | 61 ++++++++++++------- .../components/password-sign-up-container.tsx | 61 +++++++++---------- .../components/sign-up-methods-container.tsx | 31 +++++++--- .../hooks/use-sign-up-with-email-password.ts | 4 +- pnpm-lock.yaml | 15 +++++ 16 files changed, 209 insertions(+), 78 deletions(-) create mode 100644 packages/features/auth/src/components/captcha/captcha-provider.tsx create mode 100644 packages/features/auth/src/components/captcha/captchaTokenSetter.tsx create mode 100644 packages/features/auth/src/components/captcha/index.ts create mode 100644 packages/features/auth/src/components/captcha/use-captcha-token.ts diff --git a/apps/web/app/(dashboard)/home/[account]/_lib/server/team-billing.service.ts b/apps/web/app/(dashboard)/home/[account]/_lib/server/team-billing.service.ts index 3d1e0e529..70043da62 100644 --- a/apps/web/app/(dashboard)/home/[account]/_lib/server/team-billing.service.ts +++ b/apps/web/app/(dashboard)/home/[account]/_lib/server/team-billing.service.ts @@ -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) {} + /** + * @name createCheckout + * @description Creates a checkout session for a Team account + */ async createCheckout(params: z.infer) { // 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, diff --git a/apps/web/components/root-providers.tsx b/apps/web/components/root-providers.tsx index b81244144..e9950b153 100644 --- a/apps/web/components/root-providers.tsx +++ b/apps/web/components/root-providers.tsx @@ -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 ( - - - - {children} - - - + + + + + + + {children} + + + + ); diff --git a/apps/web/config/auth.config.ts b/apps/web/config/auth.config.ts index d90a6c078..70c4d4246 100644 --- a/apps/web/config/auth.config.ts +++ b/apps/web/config/auth.config.ts @@ -5,6 +5,7 @@ import { z } from 'zod'; const providers: z.ZodType = 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: { diff --git a/apps/web/package.json b/apps/web/package.json index fa988db64..375adbc1b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/public/locales/en/auth.json b/apps/web/public/locales/en/auth.json index d617722ac..1c002aae6 100644 --- a/apps/web/public/locales/en/auth.json +++ b/apps/web/public/locales/en/auth.json @@ -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.", diff --git a/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-checkout.ts b/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-checkout.ts index 9d4c3ce4b..5b2b46296 100644 --- a/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-checkout.ts +++ b/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-checkout.ts @@ -67,6 +67,8 @@ export async function createLemonSqueezyCheckout( }, productOptions: { redirectUrl: params.returnUrl, + // only show the selected variant ID + enabledVariants: [variantId], }, expiresAt: null, preview: true, diff --git a/packages/features/auth/package.json b/packages/features/auth/package.json index b71cc75e0..056336783 100644 --- a/packages/features/auth/package.json +++ b/packages/features/auth/package.json @@ -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", diff --git a/packages/features/auth/src/components/captcha/captcha-provider.tsx b/packages/features/auth/src/components/captcha/captcha-provider.tsx new file mode 100644 index 000000000..3bfc70c64 --- /dev/null +++ b/packages/features/auth/src/components/captcha/captcha-provider.tsx @@ -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(''); + + return ( + + {props.children} + + ); +} diff --git a/packages/features/auth/src/components/captcha/captchaTokenSetter.tsx b/packages/features/auth/src/components/captcha/captchaTokenSetter.tsx new file mode 100644 index 000000000..59068be39 --- /dev/null +++ b/packages/features/auth/src/components/captcha/captchaTokenSetter.tsx @@ -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 ; +} diff --git a/packages/features/auth/src/components/captcha/index.ts b/packages/features/auth/src/components/captcha/index.ts new file mode 100644 index 000000000..14f70c5f0 --- /dev/null +++ b/packages/features/auth/src/components/captcha/index.ts @@ -0,0 +1,3 @@ +export * from './captchaTokenSetter'; +export * from './use-captcha-token'; +export * from './captcha-provider'; diff --git a/packages/features/auth/src/components/captcha/use-captcha-token.ts b/packages/features/auth/src/components/captcha/use-captcha-token.ts new file mode 100644 index 000000000..461911ac7 --- /dev/null +++ b/packages/features/auth/src/components/captcha/use-captcha-token.ts @@ -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; +} diff --git a/packages/features/auth/src/components/magic-link-auth-container.tsx b/packages/features/auth/src/components/magic-link-auth-container.tsx index 30527ad27..aed7103c5 100644 --- a/packages/features/auth/src/components/magic-link-auth-container.tsx +++ b/packages/features/auth/src/components/magic-link-auth-container.tsx @@ -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 ( - - - - - - - - - - - - ); + return ; } return (
- - - - - - - - - +
@@ -130,3 +113,35 @@ export function MagicLinkAuthContainer({ ); } + +function SuccessAlert() { + return ( + + + + + + + + + + + + ); +} + +function ErrorAlert() { + return ( + + + + + + + + + + + + ); +} diff --git a/packages/features/auth/src/components/password-sign-up-container.tsx b/packages/features/auth/src/components/password-sign-up-container.tsx index 39731a4e1..c5286692f 100644 --- a/packages/features/auth/src/components/password-sign-up-container.tsx +++ b/packages/features/auth/src/components/password-sign-up-container.tsx @@ -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 ( <> - - - - - - - - - - - + @@ -86,3 +67,19 @@ export function EmailPasswordSignUpContainer({ ); } + +function SuccessAlert() { + return ( + + + + + + + + + + + + ); +} diff --git a/packages/features/auth/src/components/sign-up-methods-container.tsx b/packages/features/auth/src/components/sign-up-methods-container.tsx index 8cccb62a7..36ac4ca85 100644 --- a/packages/features/auth/src/components/sign-up-methods-container.tsx +++ b/packages/features/auth/src/components/sign-up-methods-container.tsx @@ -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 ( <> - - You have been invited to join a team - - Please sign up to continue with the invitation and create your - account. - - + - + @@ -88,3 +89,17 @@ function getCallbackUrl(props: { return url.href; } + +function InviteAlert() { + return ( + + + + + + + + + + ); +} diff --git a/packages/supabase/src/hooks/use-sign-up-with-email-password.ts b/packages/supabase/src/hooks/use-sign-up-with-email-password.ts index 0f6d7d00a..821482f93 100644 --- a/packages/supabase/src/hooks/use-sign-up-with-email-password.ts +++ b/packages/supabase/src/hooks/use-sign-up-with-email-password.ts @@ -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, }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 826d49b84..c86c43659 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: