From 3dfa6af02793dd7e349363a40eb30247ae06bc01 Mon Sep 17 00:00:00 2001 From: Rob Knight Date: Sun, 16 Nov 2025 10:55:55 -0300 Subject: [PATCH 1/2] Initial captcha verification --- apps/passport-client/build.ts | 7 ++ .../Login/NewAlreadyRegisteredScreen.tsx | 104 ++++++++++++++--- .../screens/Login/NewPassportScreen.tsx | 105 +++++++++++++++--- .../shared/ResendCodeButton.tsx | 86 +++++++++++++- apps/passport-client/package.json | 1 + apps/passport-client/src/appConfig.ts | 5 +- .../src/routing/routes/accountRoutes.ts | 3 +- .../src/services/userService.ts | 20 +++- apps/passport-server/src/types.ts | 1 + .../src/util/captchaVerification.ts | 58 ++++++++++ .../passport-interface/src/RequestTypes.ts | 5 + .../src/api/requestConfirmationEmail.ts | 6 +- turbo.json | 2 + yarn.lock | 11 +- 14 files changed, 373 insertions(+), 41 deletions(-) create mode 100644 apps/passport-server/src/util/captchaVerification.ts diff --git a/apps/passport-client/build.ts b/apps/passport-client/build.ts index 911601d97c..4a09e6ee91 100644 --- a/apps/passport-client/build.ts +++ b/apps/passport-client/build.ts @@ -101,6 +101,13 @@ const define = { process.env.PRIORITY_FEED_PROVIDER_URLS ) } + : {}), + ...(process.env.TURNSTILE_SITE_KEY !== undefined + ? { + "process.env.TURNSTILE_SITE_KEY": JSON.stringify( + process.env.TURNSTILE_SITE_KEY + ) + } : {}) }; diff --git a/apps/passport-client/new-components/screens/Login/NewAlreadyRegisteredScreen.tsx b/apps/passport-client/new-components/screens/Login/NewAlreadyRegisteredScreen.tsx index 174f1535ba..f62f55b99e 100644 --- a/apps/passport-client/new-components/screens/Login/NewAlreadyRegisteredScreen.tsx +++ b/apps/passport-client/new-components/screens/Login/NewAlreadyRegisteredScreen.tsx @@ -14,6 +14,7 @@ import { useLayoutEffect, useState } from "react"; +import { Turnstile } from "@marsidev/react-turnstile"; import styled from "styled-components"; import { AppContainer } from "../../../components/shared/AppContainer"; import { appConfig } from "../../../src/appConfig"; @@ -42,6 +43,9 @@ export const NewAlreadyRegisteredScreen: React.FC = () => { useState(false); const [password, setPassword] = useState(""); const [verifyingCode, setVerifyingCode] = useState(false); + const [showCaptcha, setShowCaptcha] = useState(false); + const [captchaToken, setCaptchaToken] = useState(); + const requiresCaptcha = !!appConfig.turnstileSiteKey; const verifyToken = useCallback( async (token: string) => { @@ -90,6 +94,26 @@ export const NewAlreadyRegisteredScreen: React.FC = () => { [email, identityCommitment, verifyToken] ); + const sendPasswordResetEmail = useCallback( + async (token?: string) => { + if (!email || !identityCommitment) { + return; + } + setSendingConfirmationEmail(true); + const emailConfirmationResult = await requestConfirmationEmail( + appConfig.zupassServer, + email, + true, + token + ); + handleConfirmationEmailResult(emailConfirmationResult); + // Reset captcha token after use + setCaptchaToken(undefined); + setShowCaptcha(false); + }, + [email, identityCommitment, handleConfirmationEmailResult] + ); + const onOverwriteClick = useCallback(async () => { if (!email || !identityCommitment) { return; @@ -99,14 +123,21 @@ export const NewAlreadyRegisteredScreen: React.FC = () => { identityCommitment }); - setSendingConfirmationEmail(true); - const emailConfirmationResult = await requestConfirmationEmail( - appConfig.zupassServer, - email, - true - ); - handleConfirmationEmailResult(emailConfirmationResult); - }, [email, identityCommitment, handleConfirmationEmailResult]); + // If captcha is required, show it first + if (requiresCaptcha && !captchaToken) { + setShowCaptcha(true); + return; + } + + await sendPasswordResetEmail(captchaToken); + }, [email, identityCommitment, requiresCaptcha, captchaToken, sendPasswordResetEmail]); + + // Auto-send email once captcha is verified + useEffect(() => { + if (showCaptcha && captchaToken) { + sendPasswordResetEmail(captchaToken); + } + }, [captchaToken, showCaptcha, sendPasswordResetEmail]); const onLoginWithMasterPasswordClick = useCallback(() => { requestLogToServer( @@ -262,16 +293,53 @@ export const NewAlreadyRegisteredScreen: React.FC = () => { Cancel -
- - {salt ? "Forgot Password?" : "Lost Sync Key?"} - -
+ {showCaptcha && requiresCaptcha ? ( + <> + + Please verify you're human to reset your password + + { + setCaptchaToken(token); + }} + onError={() => { + setError("Captcha verification failed. Please try again."); + setCaptchaToken(undefined); + setShowCaptcha(false); + }} + onExpire={() => { + setCaptchaToken(undefined); + }} + /> + { + setShowCaptcha(false); + setCaptchaToken(undefined); + }} + > + Cancel + + + ) : ( +
+ + {salt ? "Forgot Password?" : "Lost Sync Key?"} + +
+ )} diff --git a/apps/passport-client/new-components/screens/Login/NewPassportScreen.tsx b/apps/passport-client/new-components/screens/Login/NewPassportScreen.tsx index d8527504cc..28a37b13d3 100644 --- a/apps/passport-client/new-components/screens/Login/NewPassportScreen.tsx +++ b/apps/passport-client/new-components/screens/Login/NewPassportScreen.tsx @@ -1,3 +1,4 @@ +import { Turnstile } from "@marsidev/react-turnstile"; import { ConfirmEmailResult, getNamedAPIErrorMessage, @@ -56,7 +57,13 @@ const SendEmailVerification = ({ const [verifyingCode, setVerifyingCode] = useState(false); const [loadingAccount, setLoadingAccount] = useState(false); const [token, setToken] = useState(""); - const loadingPage = loadingAccount || emailSending || !emailSent; + const [captchaToken, setCaptchaToken] = useState(); + const requiresCaptcha = !!appConfig.turnstileSiteKey; + // Only show loading page if we're not waiting for captcha + const waitingForCaptcha = + requiresCaptcha && !captchaToken && !triedSendingEmail; + const loadingPage = + (loadingAccount || emailSending || !emailSent) && !waitingForCaptcha; const verifyToken = useCallback( async (token: string) => { @@ -167,23 +174,41 @@ const SendEmailVerification = ({ [dispatch, email, identity.commitment, verifyToken] ); - const doRequestConfirmationEmail = useCallback(async () => { - setEmailSending(true); - const confirmationEmailResult = await requestConfirmationEmail( - appConfig.zupassServer, - email, - false - ); - setEmailSending(false); + const doRequestConfirmationEmail = useCallback( + async (captchaToken?: string) => { + // Double-check: never send email if captcha is required but token is missing + if (requiresCaptcha && !captchaToken) { + return; + } + + setEmailSending(true); + const confirmationEmailResult = await requestConfirmationEmail( + appConfig.zupassServer, + email, + false, + captchaToken + ); + setEmailSending(false); - handleConfirmationEmailResult(confirmationEmailResult); - }, [email, handleConfirmationEmailResult]); + handleConfirmationEmailResult(confirmationEmailResult); + // Reset captcha token after use + setCaptchaToken(undefined); + }, + [email, handleConfirmationEmailResult, requiresCaptcha] + ); useEffect(() => { if (triedSendingEmail) return; + // Don't auto-send if captcha is required and not yet verified + if (requiresCaptcha && !captchaToken) return; setTriedSendingEmail(true); - doRequestConfirmationEmail(); - }, [triedSendingEmail, doRequestConfirmationEmail]); + doRequestConfirmationEmail(captchaToken); + }, [ + triedSendingEmail, + doRequestConfirmationEmail, + requiresCaptcha, + captchaToken + ]); // Verify the code the user entered. const onSubmit = useCallback( @@ -194,6 +219,60 @@ const SendEmailVerification = ({ [verifyToken, token] ); + // Show captcha if required and we haven't sent email yet + if (waitingForCaptcha) { + return ( + + + + + VERIFY YOU'RE HUMAN + + + Please complete the verification below to continue. + + + + + {appConfig.turnstileSiteKey && ( + { + setCaptchaToken(token); + }} + onError={() => { + setError("Captcha verification failed. Please try again."); + setCaptchaToken(undefined); + }} + onExpire={() => { + setCaptchaToken(undefined); + }} + /> + )} + {error && ( + + {error} + + )} + navigate("/")}> + Cancel + + + + + ); + } + if (loadingPage) { let loaderText = ""; if (verifyingCode) { diff --git a/apps/passport-client/new-components/shared/ResendCodeButton.tsx b/apps/passport-client/new-components/shared/ResendCodeButton.tsx index 09924f910c..c2281bc047 100644 --- a/apps/passport-client/new-components/shared/ResendCodeButton.tsx +++ b/apps/passport-client/new-components/shared/ResendCodeButton.tsx @@ -1,5 +1,6 @@ import { requestConfirmationEmail } from "@pcd/passport-interface"; import { useCallback, useEffect, useState } from "react"; +import { Turnstile } from "@marsidev/react-turnstile"; import styled from "styled-components"; import { appConfig } from "../../src/appConfig"; import { Typography } from "./Typography"; @@ -17,6 +18,9 @@ export function ResendCodeButton2({ // because our defense against spammers should happen with rate // limiting at the API layer. const [waitCountInSeconds, setWaitCount] = useState(10); + const [showCaptcha, setShowCaptcha] = useState(false); + const [captchaToken, setCaptchaToken] = useState(); + const requiresCaptcha = !!appConfig.turnstileSiteKey; const startTimer = useCallback(() => { // We need a local variable `timer` because relying on React state @@ -38,13 +42,83 @@ export function ResendCodeButton2({ startTimer(); }, [startTimer]); + const sendEmail = useCallback( + async (token?: string) => { + await requestConfirmationEmail( + appConfig.zupassServer, + email, + true, + token + ); + startTimer(); + // Reset captcha token after use + setCaptchaToken(undefined); + setShowCaptcha(false); + }, + [email, startTimer] + ); + const handleClick = async (): Promise => { - await requestConfirmationEmail(appConfig.zupassServer, email, true); - startTimer(); + // If captcha is required, show it first + if (requiresCaptcha && !captchaToken) { + setShowCaptcha(true); + return; + } + + await sendEmail(captchaToken); }; + // Auto-send email once captcha is verified + useEffect(() => { + if (showCaptcha && captchaToken) { + sendEmail(captchaToken); + } + }, [captchaToken, showCaptcha, sendEmail]); + const disabled = waitCountInSeconds > 0; + if (showCaptcha && requiresCaptcha) { + return ( + + + Please verify you're human to resend code + + { + setCaptchaToken(token); + }} + onError={() => { + setCaptchaToken(undefined); + setShowCaptcha(false); + }} + onExpire={() => { + setCaptchaToken(undefined); + }} + /> + { + setShowCaptcha(false); + setCaptchaToken(undefined); + }} + > + Cancel + + + ); + } + return ( { @@ -69,3 +143,11 @@ const ResendCodeButtonContainer = styled.div` cursor: pointer; user-select: none; `; + +const ResendCodeContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + margin-top: 8px; +`; diff --git a/apps/passport-client/package.json b/apps/passport-client/package.json index 3c8e7111c3..b93828ea14 100644 --- a/apps/passport-client/package.json +++ b/apps/passport-client/package.json @@ -18,6 +18,7 @@ "dependencies": { "@babel/runtime": "^7.24.0", "@heroicons/react": "^2.1.5", + "@marsidev/react-turnstile": "^1.3.1", "@parcnet-js/client-helpers": "^1.0.8", "@parcnet-js/client-rpc": "^1.2.0", "@parcnet-js/podspec": "^1.2.0", diff --git a/apps/passport-client/src/appConfig.ts b/apps/passport-client/src/appConfig.ts index d97c5aedfe..ca4d016def 100644 --- a/apps/passport-client/src/appConfig.ts +++ b/apps/passport-client/src/appConfig.ts @@ -25,6 +25,8 @@ interface AppConfig { ignoreNonPriorityFeeds: boolean; // URLs of feed providers that are priority feeds. priorityFeedProviderUrls: string[]; + // Cloudflare Turnstile site key for captcha verification + turnstileSiteKey?: string; } if ( @@ -92,7 +94,8 @@ export const appConfig: AppConfig = { ignoreNonPriorityFeeds: process.env.IGNORE_NON_PRIORITY_FEEDS === "true", priorityFeedProviderUrls: process.env.PRIORITY_FEED_PROVIDER_URLS ? JSON.parse(process.env.PRIORITY_FEED_PROVIDER_URLS) - : [] + : [], + turnstileSiteKey: process.env.TURNSTILE_SITE_KEY }; console.log("App Config: " + JSON.stringify(appConfig)); diff --git a/apps/passport-server/src/routing/routes/accountRoutes.ts b/apps/passport-server/src/routing/routes/accountRoutes.ts index f17f8f72ad..86ba742b15 100644 --- a/apps/passport-server/src/routing/routes/accountRoutes.ts +++ b/apps/passport-server/src/routing/routes/accountRoutes.ts @@ -87,9 +87,10 @@ export function initAccountRoutes( ); const force = checkBody(req, "force") === "true"; + const captchaToken = (req.body as ConfirmEmailRequest).captchaToken; const result = await sqlQueryWithPool(context.dbPool, (client) => - userService.handleSendTokenEmail(client, email, force) + userService.handleSendTokenEmail(client, email, force, captchaToken) ); if (result) { diff --git a/apps/passport-server/src/services/userService.ts b/apps/passport-server/src/services/userService.ts index 675c4a2eb3..c29b19ecf4 100644 --- a/apps/passport-server/src/services/userService.ts +++ b/apps/passport-server/src/services/userService.ts @@ -48,6 +48,7 @@ import { } from "../database/queries/users"; import { PCDHTTPError } from "../routing/pcdHttpError"; import { ApplicationContext } from "../types"; +import { verifyTurnstileToken } from "../util/captchaVerification"; import { logger } from "../util/logger"; import { userRowToZupassUserJson } from "../util/zuzaluUser"; import { EmailService } from "./emailService"; @@ -186,7 +187,8 @@ export class UserService { public async handleSendTokenEmail( client: PoolClient, email: string, - force: boolean + force: boolean, + captchaToken?: string ): Promise { logger( `[USER_SERVICE] send-token-email ${JSON.stringify({ @@ -199,6 +201,22 @@ export class UserService { throw new PCDHTTPError(400, `'${email}' is not a valid email`); } + // Verify captcha if secret key is configured + const turnstileSecretKey = process.env.TURNSTILE_SECRET_KEY; + if (turnstileSecretKey) { + if (!captchaToken) { + throw new PCDHTTPError(400, "Captcha verification required"); + } + + const isValid = await verifyTurnstileToken( + captchaToken, + turnstileSecretKey + ); + if (!isValid) { + throw new PCDHTTPError(400, "Captcha verification failed"); + } + } + if ( !(await this.rateLimitService.requestRateLimitedAction( this.context.dbPool, diff --git a/apps/passport-server/src/types.ts b/apps/passport-server/src/types.ts index 106baa538e..5f61bbcc48 100644 --- a/apps/passport-server/src/types.ts +++ b/apps/passport-server/src/types.ts @@ -128,4 +128,5 @@ export interface EnvironmentVariables { GENERIC_ISSUANCE_ZUPASS_PUBLIC_KEY?: string; PASSPORT_SERVER_URL: string; STYTCH_BYPASS?: string; + TURNSTILE_SECRET_KEY?: string; } diff --git a/apps/passport-server/src/util/captchaVerification.ts b/apps/passport-server/src/util/captchaVerification.ts new file mode 100644 index 0000000000..889c2dac50 --- /dev/null +++ b/apps/passport-server/src/util/captchaVerification.ts @@ -0,0 +1,58 @@ +import { instrumentedFetch } from "../apis/fetch"; +import { logger } from "./logger"; + +interface TurnstileVerifyResponse { + success: boolean; + "error-codes"?: string[]; + challenge_ts?: string; + hostname?: string; +} + +/** + * Verifies a Cloudflare Turnstile captcha token. + * @param token The captcha token to verify + * @param secretKey The Turnstile secret key + * @returns true if verification succeeds, false otherwise + */ +export async function verifyTurnstileToken( + token: string, + secretKey: string +): Promise { + try { + const response = await instrumentedFetch( + "https://challenges.cloudflare.com/turnstile/v0/siteverify", + { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + secret: secretKey, + response: token + }) + } + ); + + if (!response.ok) { + logger( + `[CAPTCHA] Turnstile verification request failed with status ${response.status}` + ); + return false; + } + + const result: TurnstileVerifyResponse = await response.json(); + + if (!result.success) { + logger( + `[CAPTCHA] Turnstile verification failed: ${result["error-codes"]?.join(", ") ?? "unknown error"}` + ); + return false; + } + + return true; + } catch (e) { + logger(`[CAPTCHA] Error verifying Turnstile token:`, e); + return false; + } +} + diff --git a/packages/lib/passport-interface/src/RequestTypes.ts b/packages/lib/passport-interface/src/RequestTypes.ts index 998db9f1e3..4e071640b9 100644 --- a/packages/lib/passport-interface/src/RequestTypes.ts +++ b/packages/lib/passport-interface/src/RequestTypes.ts @@ -556,6 +556,11 @@ export type ConfirmEmailRequest = { * Required to be 'true' if a user with the same email already exists. */ force: "true" | "false"; + + /** + * Optional captcha token to verify the user is not a bot. + */ + captchaToken?: string; }; /** diff --git a/packages/lib/passport-interface/src/api/requestConfirmationEmail.ts b/packages/lib/passport-interface/src/api/requestConfirmationEmail.ts index e0c4e4c0ef..8c9ceca339 100644 --- a/packages/lib/passport-interface/src/api/requestConfirmationEmail.ts +++ b/packages/lib/passport-interface/src/api/requestConfirmationEmail.ts @@ -18,7 +18,8 @@ import { httpPostSimple } from "./makeRequest"; export async function requestConfirmationEmail( zupassServerUrl: string, email: string, - force: boolean + force: boolean, + captchaToken?: string ): Promise { return httpPostSimple( urlJoin(zupassServerUrl, "/account/send-login-email"), @@ -31,7 +32,8 @@ export async function requestConfirmationEmail( }, { email, - force: force ? "true" : "false" + force: force ? "true" : "false", + captchaToken } satisfies ConfirmEmailRequest ); } diff --git a/turbo.json b/turbo.json index e1f8e039be..0f03e83f72 100644 --- a/turbo.json +++ b/turbo.json @@ -247,6 +247,8 @@ "GENERIC_ISSUANCE_TEST_MODE", "PRETIX_BATCH_ENABLED_FOR", "DB_POOL_SIZE", + "TURNSTILE_SECRET_KEY", + "TURNSTILE_SITE_KEY", "//// add env vars above this line ////" ] } diff --git a/yarn.lock b/yarn.lock index 8409eaf7c5..00e7f4c1e3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4126,6 +4126,11 @@ globby "^11.0.0" read-yaml-file "^1.1.0" +"@marsidev/react-turnstile@^1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@marsidev/react-turnstile/-/react-turnstile-1.3.1.tgz#6f91cc85924a21269e8acb9515cd17d2724d28dc" + integrity sha512-h2THG/75k4Y049hgjSGPIcajxXnh+IZAiXVbryQyVmagkboN7pJtBgR16g8akjwUBSfRrg6jw6KvPDjscQflog== + "@monaco-editor/loader@^1.4.0": version "1.4.0" resolved "https://registry.yarnpkg.com/@monaco-editor/loader/-/loader-1.4.0.tgz#f08227057331ec890fa1e903912a5b711a2ad558" @@ -20100,9 +20105,9 @@ semver@^6.3.0, semver@^6.3.1: integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== semver@^7.6.0: - version "7.7.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.0.tgz#9c6fe61d0c6f9fa9e26575162ee5a9180361b09c" - integrity sha512-DrfFnPzblFmNrIZzg5RzHegbiRWg7KMR7btwi2yjHwx06zsUbO5g613sVwEV7FTwmzJu+Io0lJe2GJ3LxqpvBQ== + version "7.7.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" + integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== send@0.18.0: version "0.18.0" From e100f04e667a799c61a14902df1c3b4c4457e948 Mon Sep 17 00:00:00 2001 From: Rob Knight Date: Sun, 16 Nov 2025 12:11:45 -0300 Subject: [PATCH 2/2] Tidy up copy --- .../screens/Login/NewAlreadyRegisteredScreen.tsx | 12 +++++++++--- .../screens/Login/NewPassportScreen.tsx | 2 +- apps/passport-server/test/user/testLogin.ts | 3 ++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/passport-client/new-components/screens/Login/NewAlreadyRegisteredScreen.tsx b/apps/passport-client/new-components/screens/Login/NewAlreadyRegisteredScreen.tsx index f62f55b99e..f34d908242 100644 --- a/apps/passport-client/new-components/screens/Login/NewAlreadyRegisteredScreen.tsx +++ b/apps/passport-client/new-components/screens/Login/NewAlreadyRegisteredScreen.tsx @@ -1,3 +1,4 @@ +import { Turnstile } from "@marsidev/react-turnstile"; import { PCDCrypto } from "@pcd/passport-crypto"; import { ConfirmEmailResult, @@ -14,7 +15,6 @@ import { useLayoutEffect, useState } from "react"; -import { Turnstile } from "@marsidev/react-turnstile"; import styled from "styled-components"; import { AppContainer } from "../../../components/shared/AppContainer"; import { appConfig } from "../../../src/appConfig"; @@ -130,7 +130,13 @@ export const NewAlreadyRegisteredScreen: React.FC = () => { } await sendPasswordResetEmail(captchaToken); - }, [email, identityCommitment, requiresCaptcha, captchaToken, sendPasswordResetEmail]); + }, [ + email, + identityCommitment, + requiresCaptcha, + captchaToken, + sendPasswordResetEmail + ]); // Auto-send email once captcha is verified useEffect(() => { @@ -302,7 +308,7 @@ export const NewAlreadyRegisteredScreen: React.FC = () => { family="Rubik" style={{ textAlign: "center", marginTop: "16px" }} > - Please verify you're human to reset your password + Please complete the captcha to reset your password - VERIFY YOU'RE HUMAN + COMPLETE VERIFICATION