Skip to content

Commit 8823acb

Browse files
committed
add Turnstile captcha to login flow (#6310)
<!-- start pr-codex --> ## PR-Codex overview This PR focuses on integrating `Turnstile` CAPTCHA for enhanced security during the login process in the `LoginPage`. It modifies the `doLogin` function to include token validation and updates the UI to present the `Turnstile` challenge. ### Detailed summary - Added `TURNSTILE_SITE_KEY` and `Turnstile` component to `LoginPage.tsx`. - Introduced state management for `turnstileToken`. - Updated `doLogin` function to accept `turnstileToken` and validate it. - Enhanced error handling for login failures. - Updated UI to display `Turnstile` CAPTCHA. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` <!-- end pr-codex -->
1 parent 035fcf6 commit 8823acb

File tree

2 files changed

+143
-34
lines changed

2 files changed

+143
-34
lines changed

apps/dashboard/src/app/login/LoginPage.tsx

Lines changed: 48 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import { redirectToCheckout } from "@/actions/billing";
44
import { getRawAccountAction } from "@/actions/getAccount";
55
import { ToggleThemeButton } from "@/components/color-mode-toggle";
66
import { Spinner } from "@/components/ui/Spinner/Spinner";
7+
import { TURNSTILE_SITE_KEY } from "@/constants/env";
78
import { useThirdwebClient } from "@/constants/thirdweb.client";
89
import { useDashboardRouter } from "@/lib/DashboardRouter";
910
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
11+
import { Turnstile } from "@marsidev/react-turnstile";
1012
import { useTheme } from "next-themes";
1113
import Link from "next/link";
1214
import { Suspense, lazy, useEffect, useState } from "react";
@@ -216,36 +218,53 @@ function CustomConnectEmbed(props: {
216218
}) {
217219
const { theme } = useTheme();
218220
const client = useThirdwebClient();
221+
const [turnstileToken, setTurnstileToken] = useState("");
219222

220223
return (
221-
<ConnectEmbed
222-
auth={{
223-
getLoginPayload,
224-
doLogin: async (params) => {
225-
try {
226-
await doLogin(params);
227-
props.onLogin();
228-
} catch (e) {
229-
console.error("Failed to login", e);
230-
throw e;
231-
}
232-
},
233-
doLogout,
234-
isLoggedIn: async (x) => {
235-
const isLoggedInResult = await isLoggedIn(x);
236-
if (isLoggedInResult) {
237-
props.onLogin();
238-
}
239-
return isLoggedInResult;
240-
},
241-
}}
242-
wallets={wallets}
243-
client={client}
244-
modalSize="wide"
245-
theme={getSDKTheme(theme === "light" ? "light" : "dark")}
246-
className="shadow-lg"
247-
privacyPolicyUrl="/privacy-policy"
248-
termsOfServiceUrl="/terms"
249-
/>
224+
<div className="flex flex-col items-center gap-4">
225+
<ConnectEmbed
226+
auth={{
227+
getLoginPayload,
228+
doLogin: async (params) => {
229+
try {
230+
const result = await doLogin(params, turnstileToken);
231+
if (result.error) {
232+
console.error("Failed to login", result.error, result.context);
233+
throw new Error(result.error);
234+
}
235+
props.onLogin();
236+
} catch (e) {
237+
console.error("Failed to login", e);
238+
throw e;
239+
}
240+
},
241+
doLogout,
242+
isLoggedIn: async (x) => {
243+
const isLoggedInResult = await isLoggedIn(x);
244+
if (isLoggedInResult) {
245+
props.onLogin();
246+
}
247+
return isLoggedInResult;
248+
},
249+
}}
250+
wallets={wallets}
251+
client={client}
252+
modalSize="wide"
253+
theme={getSDKTheme(theme === "light" ? "light" : "dark")}
254+
className="shadow-lg"
255+
privacyPolicyUrl="/privacy-policy"
256+
termsOfServiceUrl="/terms"
257+
/>
258+
<Turnstile
259+
options={{
260+
// only show if interaction is required
261+
appearance: "interaction-only",
262+
// match the theme of the rest of the app
263+
theme: theme === "light" ? "light" : "dark",
264+
}}
265+
siteKey={TURNSTILE_SITE_KEY}
266+
onSuccess={(token) => setTurnstileToken(token)}
267+
/>
268+
</div>
250269
);
251270
}

apps/dashboard/src/app/login/auth-actions.ts

Lines changed: 95 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import "server-only";
33

44
import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
55
import { API_SERVER_URL, THIRDWEB_API_SECRET } from "@/constants/env";
6-
import { cookies } from "next/headers";
6+
import { ipAddress } from "@vercel/functions";
7+
import { cookies, headers } from "next/headers";
78
import { getAddress } from "thirdweb";
89
import type {
910
GenerateLoginPayloadParams,
@@ -36,11 +37,90 @@ export async function getLoginPayload(
3637
return (await res.json()).data.payload;
3738
}
3839

39-
export async function doLogin(payload: VerifyLoginPayloadParams) {
40+
export async function doLogin(
41+
payload: VerifyLoginPayloadParams,
42+
turnstileToken: string,
43+
) {
4044
if (!THIRDWEB_API_SECRET) {
4145
throw new Error("API_SERVER_SECRET is not set");
4246
}
4347

48+
if (!turnstileToken) {
49+
return {
50+
error: "Missing Turnstile token.",
51+
};
52+
}
53+
54+
// get the request headers
55+
const requestHeaders = await headers();
56+
if (!requestHeaders) {
57+
return {
58+
error: "Failed to get request headers. Please try again.",
59+
};
60+
}
61+
// CF header, fallback to req.ip, then X-Forwarded-For
62+
const [ip, errors] = (() => {
63+
let ip: string | null = null;
64+
const errors: string[] = [];
65+
try {
66+
ip = requestHeaders.get("CF-Connecting-IP") || null;
67+
} catch (err) {
68+
console.error("failed to get IP address from CF-Connecting-IP", err);
69+
errors.push("failed to get IP address from CF-Connecting-IP");
70+
}
71+
if (!ip) {
72+
try {
73+
ip = ipAddress(requestHeaders) || null;
74+
} catch (err) {
75+
console.error(
76+
"failed to get IP address from ipAddress() function",
77+
err,
78+
);
79+
errors.push("failed to get IP address from ipAddress() function");
80+
}
81+
}
82+
if (!ip) {
83+
try {
84+
ip = requestHeaders.get("X-Forwarded-For");
85+
} catch (err) {
86+
console.error("failed to get IP address from X-Forwarded-For", err);
87+
errors.push("failed to get IP address from X-Forwarded-For");
88+
}
89+
}
90+
return [ip, errors];
91+
})();
92+
93+
if (!ip) {
94+
return {
95+
error: "Could not get IP address. Please try again.",
96+
context: errors,
97+
};
98+
}
99+
100+
// https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
101+
// Validate the token by calling the "/siteverify" API endpoint.
102+
const result = await fetch(
103+
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
104+
{
105+
body: JSON.stringify({
106+
secret: process.env.TURNSTILE_SECRET_KEY,
107+
response: turnstileToken,
108+
remoteip: ip,
109+
}),
110+
method: "POST",
111+
headers: {
112+
"Content-Type": "application/json",
113+
},
114+
},
115+
);
116+
117+
const outcome = await result.json();
118+
if (!outcome.success) {
119+
return {
120+
error: "Could not validate captcha.",
121+
};
122+
}
123+
44124
const cookieStore = await cookies();
45125
const utmCookies = cookieStore
46126
.getAll()
@@ -86,7 +166,9 @@ export async function doLogin(payload: VerifyLoginPayloadParams) {
86166
res.statusText,
87167
response,
88168
);
89-
throw new Error("Failed to login - api call failed");
169+
return {
170+
error: "Failed to login. Please try again later.",
171+
};
90172
} catch {
91173
// just log the basics
92174
console.error(
@@ -95,7 +177,9 @@ export async function doLogin(payload: VerifyLoginPayloadParams) {
95177
res.statusText,
96178
);
97179
}
98-
throw new Error("Failed to login - api call failed");
180+
return {
181+
error: "Failed to login. Please try again later.",
182+
};
99183
}
100184

101185
const json = await res.json();
@@ -104,7 +188,9 @@ export async function doLogin(payload: VerifyLoginPayloadParams) {
104188

105189
if (!jwt) {
106190
console.error("Failed to login - invalid json", json);
107-
throw new Error("Failed to login - invalid json");
191+
return {
192+
error: "Failed to login. Please try again later.",
193+
};
108194
}
109195

110196
// set the token cookie
@@ -128,6 +214,10 @@ export async function doLogin(payload: VerifyLoginPayloadParams) {
128214
// 3 days
129215
maxAge: 3 * 24 * 60 * 60,
130216
});
217+
218+
return {
219+
success: true,
220+
};
131221
}
132222

133223
export async function doLogout() {

0 commit comments

Comments
 (0)