Add Cloudflare Turnstile captcha on email signup (#326)
This commit is contained in:
parent
97f026562e
commit
fffdbc9329
127
src/client/features/auth/TurnstileWidget.tsx
Normal file
127
src/client/features/auth/TurnstileWidget.tsx
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
const TURNSTILE_SCRIPT_SRC =
|
||||||
|
"https://challenges.cloudflare.com/turnstile/v0/api.js";
|
||||||
|
|
||||||
|
// Public Turnstile site key, inlined at build time (see vite.config envPrefix).
|
||||||
|
// The widget only renders when it's set, so unconfigured / self-hosted builds
|
||||||
|
// are unaffected. The matching TURNSTILE_SECRET_KEY lives server-side only.
|
||||||
|
export const TURNSTILE_SITE_KEY = import.meta.env.TURNSTILE_SITE_KEY?.trim();
|
||||||
|
|
||||||
|
type TurnstileApi = {
|
||||||
|
render: (
|
||||||
|
element: HTMLElement,
|
||||||
|
options: {
|
||||||
|
sitekey: string;
|
||||||
|
callback: (token: string) => void;
|
||||||
|
"expired-callback"?: () => void;
|
||||||
|
"error-callback"?: () => void;
|
||||||
|
},
|
||||||
|
) => string;
|
||||||
|
reset: (widgetId: string) => void;
|
||||||
|
remove: (widgetId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
turnstile?: TurnstileApi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Captcha state for a form: the token in a ref (read at submit time, so the
|
||||||
|
// form's submit closure never sees a stale value), a boolean mirror to drive
|
||||||
|
// the submit button, and a reset (tokens are single-use — re-challenge after a
|
||||||
|
// failed submit). Wire `onToken`/`resetNonce` into <TurnstileWidget />.
|
||||||
|
export function useTurnstileCaptcha() {
|
||||||
|
const tokenRef = useRef<string | null>(null);
|
||||||
|
const [hasToken, setHasToken] = useState(false);
|
||||||
|
const [resetNonce, setResetNonce] = useState(0);
|
||||||
|
|
||||||
|
const onToken = useCallback((token: string | null) => {
|
||||||
|
tokenRef.current = token;
|
||||||
|
setHasToken(Boolean(token));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
tokenRef.current = null;
|
||||||
|
setHasToken(false);
|
||||||
|
setResetNonce((nonce) => nonce + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { tokenRef, hasToken, resetNonce, onToken, reset };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renders the Cloudflare Turnstile challenge and reports its token. `onToken`
|
||||||
|
// fires with the token when solved and with null when it expires/errors.
|
||||||
|
// Bump `resetNonce` to re-challenge (tokens are single-use, so reset after a
|
||||||
|
// failed submit).
|
||||||
|
export function TurnstileWidget({
|
||||||
|
onToken,
|
||||||
|
resetNonce,
|
||||||
|
}: {
|
||||||
|
onToken: (token: string | null) => void;
|
||||||
|
resetNonce: number;
|
||||||
|
}) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const widgetIdRef = useRef<string | null>(null);
|
||||||
|
// Keep the latest callback in a ref so mounting the widget stays a one-time
|
||||||
|
// effect (a fresh onToken each render must not tear down and re-render it).
|
||||||
|
const onTokenRef = useRef(onToken);
|
||||||
|
onTokenRef.current = onToken;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!TURNSTILE_SITE_KEY) return;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const renderWidget = () => {
|
||||||
|
if (
|
||||||
|
cancelled ||
|
||||||
|
widgetIdRef.current !== null ||
|
||||||
|
!containerRef.current ||
|
||||||
|
!window.turnstile
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
widgetIdRef.current = window.turnstile.render(containerRef.current, {
|
||||||
|
sitekey: TURNSTILE_SITE_KEY,
|
||||||
|
callback: (token) => onTokenRef.current(token),
|
||||||
|
"expired-callback": () => onTokenRef.current(null),
|
||||||
|
"error-callback": () => onTokenRef.current(null),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (window.turnstile) {
|
||||||
|
renderWidget();
|
||||||
|
} else {
|
||||||
|
const existing = document.querySelector<HTMLScriptElement>(
|
||||||
|
`script[src="${TURNSTILE_SCRIPT_SRC}"]`,
|
||||||
|
);
|
||||||
|
const script = existing ?? document.createElement("script");
|
||||||
|
script.addEventListener("load", renderWidget);
|
||||||
|
if (!existing) {
|
||||||
|
script.src = TURNSTILE_SCRIPT_SRC;
|
||||||
|
script.async = true;
|
||||||
|
document.head.appendChild(script);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (widgetIdRef.current !== null && window.turnstile) {
|
||||||
|
window.turnstile.remove(widgetIdRef.current);
|
||||||
|
}
|
||||||
|
widgetIdRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (resetNonce === 0) return;
|
||||||
|
if (widgetIdRef.current !== null && window.turnstile) {
|
||||||
|
window.turnstile.reset(widgetIdRef.current);
|
||||||
|
onTokenRef.current(null);
|
||||||
|
}
|
||||||
|
}, [resetNonce]);
|
||||||
|
|
||||||
|
if (!TURNSTILE_SITE_KEY) return null;
|
||||||
|
return <div ref={containerRef} className="flex justify-center" />;
|
||||||
|
}
|
||||||
6
src/env.d.ts
vendored
6
src/env.d.ts
vendored
@ -30,6 +30,11 @@ declare namespace Cloudflare {
|
|||||||
AUTUMN_SECRET_KEY?: string;
|
AUTUMN_SECRET_KEY?: string;
|
||||||
AUTUMN_WEBHOOK_SECRET?: string;
|
AUTUMN_WEBHOOK_SECRET?: string;
|
||||||
|
|
||||||
|
// Cloudflare Turnstile — signup captcha (hosted only). Secret verifies
|
||||||
|
// tokens server-side; site key is public and inlined into the client build.
|
||||||
|
TURNSTILE_SECRET_KEY?: string;
|
||||||
|
TURNSTILE_SITE_KEY?: string;
|
||||||
|
|
||||||
// DataForSEO API Basic auth value (base64 of login:password)
|
// DataForSEO API Basic auth value (base64 of login:password)
|
||||||
DATAFORSEO_API_KEY: string;
|
DATAFORSEO_API_KEY: string;
|
||||||
|
|
||||||
@ -46,6 +51,7 @@ interface ImportMetaEnv {
|
|||||||
readonly BYPASS_EMAIL_VERIFICATION?: string;
|
readonly BYPASS_EMAIL_VERIFICATION?: string;
|
||||||
readonly POSTHOG_PUBLIC_KEY?: string;
|
readonly POSTHOG_PUBLIC_KEY?: string;
|
||||||
readonly POSTHOG_HOST?: string;
|
readonly POSTHOG_HOST?: string;
|
||||||
|
readonly TURNSTILE_SITE_KEY?: string;
|
||||||
readonly VITE_E2E_DOMAIN_FIXTURES?: string;
|
readonly VITE_E2E_DOMAIN_FIXTURES?: string;
|
||||||
readonly VITE_E2E_KEYWORD_FIXTURES?: string;
|
readonly VITE_E2E_KEYWORD_FIXTURES?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { env } from "cloudflare:workers";
|
import { env } from "cloudflare:workers";
|
||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
import { APIError } from "better-auth/api";
|
import { APIError } from "better-auth/api";
|
||||||
|
import { captcha } from "better-auth/plugins";
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
import { tanstackStartCookies } from "better-auth/tanstack-start";
|
import { tanstackStartCookies } from "better-auth/tanstack-start";
|
||||||
import { isDisposableEmailDomain } from "@/server/auth/disposable-email";
|
import { isDisposableEmailDomain } from "@/server/auth/disposable-email";
|
||||||
@ -40,6 +41,18 @@ function createAuth() {
|
|||||||
const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true";
|
const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true";
|
||||||
const baseAuthConfig = createBaseAuthConfig();
|
const baseAuthConfig = createBaseAuthConfig();
|
||||||
|
|
||||||
|
// Turnstile captcha on signup — hosted only, and only when BOTH keys are set.
|
||||||
|
// Requiring the site key too (not just the secret) keeps the server in
|
||||||
|
// lockstep with the client widget, which renders only when the site key is
|
||||||
|
// present: a secret-only deploy would otherwise fail closed and reject every
|
||||||
|
// signup (client sends no token). Left off entirely when unconfigured so
|
||||||
|
// local/self-hosted builds are unaffected. Relies on the same
|
||||||
|
// build-env == runtime-env contract as AUTH_MODE.
|
||||||
|
const turnstileSecretKey =
|
||||||
|
isHostedAuthMode(env.AUTH_MODE) && env.TURNSTILE_SITE_KEY?.trim()
|
||||||
|
? env.TURNSTILE_SECRET_KEY?.trim()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const database =
|
const database =
|
||||||
getDatabaseProvider() === "postgres"
|
getDatabaseProvider() === "postgres"
|
||||||
? drizzleAdapter(pgDb, {
|
? drizzleAdapter(pgDb, {
|
||||||
@ -82,7 +95,19 @@ function createAuth() {
|
|||||||
socialProviders: getSocialProviders(),
|
socialProviders: getSocialProviders(),
|
||||||
trustedOrigins: getTrustedOrigins(baseUrl),
|
trustedOrigins: getTrustedOrigins(baseUrl),
|
||||||
database,
|
database,
|
||||||
plugins: [...baseAuthConfig.plugins, tanstackStartCookies()],
|
plugins: [
|
||||||
|
...baseAuthConfig.plugins,
|
||||||
|
...(turnstileSecretKey
|
||||||
|
? [
|
||||||
|
captcha({
|
||||||
|
provider: "cloudflare-turnstile",
|
||||||
|
secretKey: turnstileSecretKey,
|
||||||
|
endpoints: ["/sign-up/email"],
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
tanstackStartCookies(),
|
||||||
|
],
|
||||||
databaseHooks: {
|
databaseHooks: {
|
||||||
user: {
|
user: {
|
||||||
create: {
|
create: {
|
||||||
|
|||||||
@ -7,6 +7,11 @@ import {
|
|||||||
authRedirectSearchSchema,
|
authRedirectSearchSchema,
|
||||||
useAuthPageState,
|
useAuthPageState,
|
||||||
} from "@/client/features/auth/AuthPage";
|
} from "@/client/features/auth/AuthPage";
|
||||||
|
import {
|
||||||
|
TURNSTILE_SITE_KEY,
|
||||||
|
TurnstileWidget,
|
||||||
|
useTurnstileCaptcha,
|
||||||
|
} from "@/client/features/auth/TurnstileWidget";
|
||||||
import { getFieldError, getFormError } from "@/client/lib/forms";
|
import { getFieldError, getFormError } from "@/client/lib/forms";
|
||||||
import { captureClientEvent } from "@/client/lib/posthog";
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
@ -49,8 +54,11 @@ function SignUpPage() {
|
|||||||
const { redirectTo, isHostedMode } = useAuthPageState(search.redirect);
|
const { redirectTo, isHostedMode } = useAuthPageState(search.redirect);
|
||||||
const postSignupRedirect = redirectTo === "/" ? "/onboarding" : redirectTo;
|
const postSignupRedirect = redirectTo === "/" ? "/onboarding" : redirectTo;
|
||||||
const [showEmailForm, setShowEmailForm] = useState(false);
|
const [showEmailForm, setShowEmailForm] = useState(false);
|
||||||
const [isStartingGoogle, setIsStartingGoogle] = useState(false);
|
const google = useGoogleSignUp({ redirectTo, postSignupRedirect });
|
||||||
const [socialError, setSocialError] = useState<string | null>(null);
|
|
||||||
|
// Turnstile is active only in hosted mode with a configured site key.
|
||||||
|
const isTurnstileEnabled = isHostedMode && Boolean(TURNSTILE_SITE_KEY);
|
||||||
|
const captcha = useTurnstileCaptcha();
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@ -63,6 +71,16 @@ function SignUpPage() {
|
|||||||
onSubmit: signUpSchema,
|
onSubmit: signUpSchema,
|
||||||
},
|
},
|
||||||
onSubmit: async ({ formApi, value }) => {
|
onSubmit: async ({ formApi, value }) => {
|
||||||
|
const captchaToken = captcha.tokenRef.current;
|
||||||
|
if (isTurnstileEnabled && !captchaToken) {
|
||||||
|
formApi.setErrorMap({
|
||||||
|
onSubmit: {
|
||||||
|
form: "Please complete the captcha to continue.",
|
||||||
|
fields: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const email = value.email.trim();
|
const email = value.email.trim();
|
||||||
captureClientEvent("auth:sign_up_submit", {
|
captureClientEvent("auth:sign_up_submit", {
|
||||||
@ -89,9 +107,18 @@ function SignUpPage() {
|
|||||||
email,
|
email,
|
||||||
password: value.password,
|
password: value.password,
|
||||||
callbackURL: verificationCallbackURL.toString(),
|
callbackURL: verificationCallbackURL.toString(),
|
||||||
|
...(isTurnstileEnabled && captchaToken
|
||||||
|
? {
|
||||||
|
fetchOptions: {
|
||||||
|
headers: { "x-captcha-response": captchaToken },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
|
// Turnstile tokens are single-use; re-challenge so a retry can succeed.
|
||||||
|
if (isTurnstileEnabled) captcha.reset();
|
||||||
formApi.setErrorMap({
|
formApi.setErrorMap({
|
||||||
onSubmit: {
|
onSubmit: {
|
||||||
form: result.error.message || "Unable to create account.",
|
form: result.error.message || "Unable to create account.",
|
||||||
@ -110,6 +137,7 @@ function SignUpPage() {
|
|||||||
replace: true,
|
replace: true,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
if (isTurnstileEnabled) captcha.reset();
|
||||||
formApi.setErrorMap({
|
formApi.setErrorMap({
|
||||||
onSubmit: {
|
onSubmit: {
|
||||||
form: "Unable to create account right now. Please try again.",
|
form: "Unable to create account right now. Please try again.",
|
||||||
@ -120,33 +148,6 @@ function SignUpPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handleContinueWithGoogle() {
|
|
||||||
setSocialError(null);
|
|
||||||
setIsStartingGoogle(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
captureClientEvent("auth:sign_up_google_start", {
|
|
||||||
redirect_to: redirectTo,
|
|
||||||
});
|
|
||||||
const result = await authClient.signIn.social({
|
|
||||||
provider: "google",
|
|
||||||
callbackURL: redirectTo,
|
|
||||||
newUserCallbackURL: postSignupRedirect,
|
|
||||||
requestSignUp: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
setSocialError(
|
|
||||||
result.error.message || "Google sign up is not available right now.",
|
|
||||||
);
|
|
||||||
setIsStartingGoogle(false);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setSocialError("Google sign up is not available right now.");
|
|
||||||
setIsStartingGoogle(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthPageCard
|
<AuthPageCard
|
||||||
title="Create your account"
|
title="Create your account"
|
||||||
@ -158,7 +159,7 @@ function SignUpPage() {
|
|||||||
className="text-sm text-base-content underline underline-offset-2 hover:text-base-content/80 transition-colors"
|
className="text-sm text-base-content underline underline-offset-2 hover:text-base-content/80 transition-colors"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowEmailForm(false);
|
setShowEmailForm(false);
|
||||||
setSocialError(null);
|
google.clearError();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Back to signup
|
Back to signup
|
||||||
@ -207,17 +208,17 @@ function SignUpPage() {
|
|||||||
<AuthMethodChooser
|
<AuthMethodChooser
|
||||||
googleLabel="Continue with Google"
|
googleLabel="Continue with Google"
|
||||||
disabled={!isHostedMode}
|
disabled={!isHostedMode}
|
||||||
isBusy={isStartingGoogle}
|
isBusy={google.isStarting}
|
||||||
onContinueWithGoogle={() => {
|
onContinueWithGoogle={() => {
|
||||||
void handleContinueWithGoogle();
|
void google.start();
|
||||||
}}
|
}}
|
||||||
onContinueWithEmail={() => {
|
onContinueWithEmail={() => {
|
||||||
setShowEmailForm(true);
|
setShowEmailForm(true);
|
||||||
setSocialError(null);
|
google.clearError();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{socialError ? (
|
{google.error ? (
|
||||||
<p className="text-sm text-error">{socialError}</p>
|
<p className="text-sm text-error">{google.error}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@ -327,6 +328,13 @@ function SignUpPage() {
|
|||||||
}}
|
}}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
|
|
||||||
|
{isTurnstileEnabled ? (
|
||||||
|
<TurnstileWidget
|
||||||
|
onToken={captcha.onToken}
|
||||||
|
resetNonce={captcha.resetNonce}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<form.Subscribe
|
<form.Subscribe
|
||||||
selector={(state) => ({
|
selector={(state) => ({
|
||||||
submitError: state.errorMap.onSubmit,
|
submitError: state.errorMap.onSubmit,
|
||||||
@ -342,7 +350,11 @@ function SignUpPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
className="btn btn-soft w-full"
|
className="btn btn-soft w-full"
|
||||||
disabled={!isHostedMode || isSubmitting}
|
disabled={
|
||||||
|
!isHostedMode ||
|
||||||
|
isSubmitting ||
|
||||||
|
(isTurnstileEnabled && !captcha.hasToken)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isSubmitting ? "Creating account..." : "Create account"}
|
{isSubmitting ? "Creating account..." : "Create account"}
|
||||||
</button>
|
</button>
|
||||||
@ -355,3 +367,49 @@ function SignUpPage() {
|
|||||||
</AuthPageCard>
|
</AuthPageCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Google sign-up: kicks off the social OAuth redirect and surfaces its error.
|
||||||
|
function useGoogleSignUp({
|
||||||
|
redirectTo,
|
||||||
|
postSignupRedirect,
|
||||||
|
}: {
|
||||||
|
redirectTo: string;
|
||||||
|
postSignupRedirect: string;
|
||||||
|
}) {
|
||||||
|
const [isStarting, setIsStarting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const start = async () => {
|
||||||
|
setError(null);
|
||||||
|
setIsStarting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
captureClientEvent("auth:sign_up_google_start", {
|
||||||
|
redirect_to: redirectTo,
|
||||||
|
});
|
||||||
|
const result = await authClient.signIn.social({
|
||||||
|
provider: "google",
|
||||||
|
callbackURL: redirectTo,
|
||||||
|
newUserCallbackURL: postSignupRedirect,
|
||||||
|
requestSignUp: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
setError(
|
||||||
|
result.error.message || "Google sign up is not available right now.",
|
||||||
|
);
|
||||||
|
setIsStarting(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Google sign up is not available right now.");
|
||||||
|
setIsStarting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isStarting,
|
||||||
|
error,
|
||||||
|
start,
|
||||||
|
clearError: () => setError(null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -27,6 +27,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
"BYPASS_EMAIL_VERIFICATION",
|
"BYPASS_EMAIL_VERIFICATION",
|
||||||
"POSTHOG_PUBLIC_KEY",
|
"POSTHOG_PUBLIC_KEY",
|
||||||
"POSTHOG_HOST",
|
"POSTHOG_HOST",
|
||||||
|
"TURNSTILE_SITE_KEY",
|
||||||
],
|
],
|
||||||
server: {
|
server: {
|
||||||
allowedHosts,
|
allowedHosts,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user