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_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_KEY: string;
|
||||
|
||||
@ -46,6 +51,7 @@ interface ImportMetaEnv {
|
||||
readonly BYPASS_EMAIL_VERIFICATION?: string;
|
||||
readonly POSTHOG_PUBLIC_KEY?: string;
|
||||
readonly POSTHOG_HOST?: string;
|
||||
readonly TURNSTILE_SITE_KEY?: string;
|
||||
readonly VITE_E2E_DOMAIN_FIXTURES?: string;
|
||||
readonly VITE_E2E_KEYWORD_FIXTURES?: string;
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { APIError } from "better-auth/api";
|
||||
import { captcha } from "better-auth/plugins";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { tanstackStartCookies } from "better-auth/tanstack-start";
|
||||
import { isDisposableEmailDomain } from "@/server/auth/disposable-email";
|
||||
@ -40,6 +41,18 @@ function createAuth() {
|
||||
const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true";
|
||||
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 =
|
||||
getDatabaseProvider() === "postgres"
|
||||
? drizzleAdapter(pgDb, {
|
||||
@ -82,7 +95,19 @@ function createAuth() {
|
||||
socialProviders: getSocialProviders(),
|
||||
trustedOrigins: getTrustedOrigins(baseUrl),
|
||||
database,
|
||||
plugins: [...baseAuthConfig.plugins, tanstackStartCookies()],
|
||||
plugins: [
|
||||
...baseAuthConfig.plugins,
|
||||
...(turnstileSecretKey
|
||||
? [
|
||||
captcha({
|
||||
provider: "cloudflare-turnstile",
|
||||
secretKey: turnstileSecretKey,
|
||||
endpoints: ["/sign-up/email"],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
tanstackStartCookies(),
|
||||
],
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
|
||||
@ -7,6 +7,11 @@ import {
|
||||
authRedirectSearchSchema,
|
||||
useAuthPageState,
|
||||
} from "@/client/features/auth/AuthPage";
|
||||
import {
|
||||
TURNSTILE_SITE_KEY,
|
||||
TurnstileWidget,
|
||||
useTurnstileCaptcha,
|
||||
} from "@/client/features/auth/TurnstileWidget";
|
||||
import { getFieldError, getFormError } from "@/client/lib/forms";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
@ -49,8 +54,11 @@ function SignUpPage() {
|
||||
const { redirectTo, isHostedMode } = useAuthPageState(search.redirect);
|
||||
const postSignupRedirect = redirectTo === "/" ? "/onboarding" : redirectTo;
|
||||
const [showEmailForm, setShowEmailForm] = useState(false);
|
||||
const [isStartingGoogle, setIsStartingGoogle] = useState(false);
|
||||
const [socialError, setSocialError] = useState<string | null>(null);
|
||||
const google = useGoogleSignUp({ redirectTo, postSignupRedirect });
|
||||
|
||||
// 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({
|
||||
defaultValues: {
|
||||
@ -63,6 +71,16 @@ function SignUpPage() {
|
||||
onSubmit: signUpSchema,
|
||||
},
|
||||
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 {
|
||||
const email = value.email.trim();
|
||||
captureClientEvent("auth:sign_up_submit", {
|
||||
@ -89,9 +107,18 @@ function SignUpPage() {
|
||||
email,
|
||||
password: value.password,
|
||||
callbackURL: verificationCallbackURL.toString(),
|
||||
...(isTurnstileEnabled && captchaToken
|
||||
? {
|
||||
fetchOptions: {
|
||||
headers: { "x-captcha-response": captchaToken },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
// Turnstile tokens are single-use; re-challenge so a retry can succeed.
|
||||
if (isTurnstileEnabled) captcha.reset();
|
||||
formApi.setErrorMap({
|
||||
onSubmit: {
|
||||
form: result.error.message || "Unable to create account.",
|
||||
@ -110,6 +137,7 @@ function SignUpPage() {
|
||||
replace: true,
|
||||
});
|
||||
} catch {
|
||||
if (isTurnstileEnabled) captcha.reset();
|
||||
formApi.setErrorMap({
|
||||
onSubmit: {
|
||||
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 (
|
||||
<AuthPageCard
|
||||
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"
|
||||
onClick={() => {
|
||||
setShowEmailForm(false);
|
||||
setSocialError(null);
|
||||
google.clearError();
|
||||
}}
|
||||
>
|
||||
Back to signup
|
||||
@ -207,17 +208,17 @@ function SignUpPage() {
|
||||
<AuthMethodChooser
|
||||
googleLabel="Continue with Google"
|
||||
disabled={!isHostedMode}
|
||||
isBusy={isStartingGoogle}
|
||||
isBusy={google.isStarting}
|
||||
onContinueWithGoogle={() => {
|
||||
void handleContinueWithGoogle();
|
||||
void google.start();
|
||||
}}
|
||||
onContinueWithEmail={() => {
|
||||
setShowEmailForm(true);
|
||||
setSocialError(null);
|
||||
google.clearError();
|
||||
}}
|
||||
/>
|
||||
{socialError ? (
|
||||
<p className="text-sm text-error">{socialError}</p>
|
||||
{google.error ? (
|
||||
<p className="text-sm text-error">{google.error}</p>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
@ -327,6 +328,13 @@ function SignUpPage() {
|
||||
}}
|
||||
</form.Field>
|
||||
|
||||
{isTurnstileEnabled ? (
|
||||
<TurnstileWidget
|
||||
onToken={captcha.onToken}
|
||||
resetNonce={captcha.resetNonce}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<form.Subscribe
|
||||
selector={(state) => ({
|
||||
submitError: state.errorMap.onSubmit,
|
||||
@ -342,7 +350,11 @@ function SignUpPage() {
|
||||
) : null}
|
||||
<button
|
||||
className="btn btn-soft w-full"
|
||||
disabled={!isHostedMode || isSubmitting}
|
||||
disabled={
|
||||
!isHostedMode ||
|
||||
isSubmitting ||
|
||||
(isTurnstileEnabled && !captcha.hasToken)
|
||||
}
|
||||
>
|
||||
{isSubmitting ? "Creating account..." : "Create account"}
|
||||
</button>
|
||||
@ -355,3 +367,49 @@ function SignUpPage() {
|
||||
</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",
|
||||
"POSTHOG_PUBLIC_KEY",
|
||||
"POSTHOG_HOST",
|
||||
"TURNSTILE_SITE_KEY",
|
||||
],
|
||||
server: {
|
||||
allowedHosts,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user