Skip to content

Commit 20f35fe

Browse files
rajat1saxenagoogle-labs-jules[bot]Rajat Saxena
authored
Google Recaptcha support for login screen (#607)
* feat: Integrate reCAPTCHA into Login Form -- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: Rajat Saxena <hi@rajatsaxena.dev>
1 parent 7c1fa58 commit 20f35fe

File tree

8 files changed

+245
-16
lines changed

8 files changed

+245
-16
lines changed

apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx

Lines changed: 84 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { ThemeContext } from "@components/contexts";
3+
import { ServerConfigContext, ThemeContext } from "@components/contexts";
44
import {
55
Button,
66
Caption,
@@ -32,7 +32,8 @@ import {
3232
} from "@/ui-config/strings";
3333
import Link from "next/link";
3434
import { TriangleAlert } from "lucide-react";
35-
import { useRouter } from "next/navigation";
35+
import { useRecaptcha } from "@/hooks/use-recaptcha";
36+
import RecaptchaScriptLoader from "@/components/recaptcha-script-loader";
3637

3738
export default function LoginForm({ redirectTo }: { redirectTo?: string }) {
3839
const { theme } = useContext(ThemeContext);
@@ -42,26 +43,98 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) {
4243
const [error, setError] = useState("");
4344
const [loading, setLoading] = useState(false);
4445
const { toast } = useToast();
45-
const router = useRouter();
46+
const serverConfig = useContext(ServerConfigContext);
47+
const { executeRecaptcha } = useRecaptcha();
4648

4749
const requestCode = async function (e: FormEvent) {
4850
e.preventDefault();
49-
const url = `/api/auth/code/generate?email=${encodeURIComponent(
50-
email,
51-
)}`;
51+
setLoading(true);
52+
setError("");
53+
54+
if (serverConfig.recaptchaSiteKey) {
55+
if (!executeRecaptcha) {
56+
toast({
57+
title: TOAST_TITLE_ERROR,
58+
description:
59+
"reCAPTCHA service not available. Please try again later.",
60+
variant: "destructive",
61+
});
62+
setLoading(false);
63+
return;
64+
}
65+
66+
const recaptchaToken = await executeRecaptcha("login_code_request");
67+
if (!recaptchaToken) {
68+
toast({
69+
title: TOAST_TITLE_ERROR,
70+
description:
71+
"reCAPTCHA validation failed. Please try again.",
72+
variant: "destructive",
73+
});
74+
setLoading(false);
75+
return;
76+
}
77+
try {
78+
const recaptchaVerificationResponse = await fetch(
79+
"/api/recaptcha",
80+
{
81+
method: "POST",
82+
headers: { "Content-Type": "application/json" },
83+
body: JSON.stringify({ token: recaptchaToken }),
84+
},
85+
);
86+
87+
const recaptchaData =
88+
await recaptchaVerificationResponse.json();
89+
90+
if (
91+
!recaptchaVerificationResponse.ok ||
92+
!recaptchaData.success ||
93+
(recaptchaData.score && recaptchaData.score < 0.5)
94+
) {
95+
toast({
96+
title: TOAST_TITLE_ERROR,
97+
description: `reCAPTCHA verification failed. ${recaptchaData.score ? `Score: ${recaptchaData.score.toFixed(2)}.` : ""} Please try again.`,
98+
variant: "destructive",
99+
});
100+
setLoading(false);
101+
return;
102+
}
103+
} catch (err) {
104+
console.error("Error during reCAPTCHA verification:", err);
105+
toast({
106+
title: TOAST_TITLE_ERROR,
107+
description:
108+
"reCAPTCHA verification failed. Please try again.",
109+
variant: "destructive",
110+
});
111+
setLoading(false);
112+
return;
113+
}
114+
}
115+
52116
try {
53-
setLoading(true);
117+
const url = `/api/auth/code/generate?email=${encodeURIComponent(
118+
email,
119+
)}`;
54120
const response = await fetch(url);
55121
const resp = await response.json();
56122
if (response.ok) {
57123
setShowCode(true);
58124
} else {
59125
toast({
60126
title: TOAST_TITLE_ERROR,
61-
description: resp.error,
127+
description: resp.error || "Failed to request code.",
62128
variant: "destructive",
63129
});
64130
}
131+
} catch (err) {
132+
console.error("Error during requestCode:", err);
133+
toast({
134+
title: TOAST_TITLE_ERROR,
135+
description: "An unexpected error occurred. Please try again.",
136+
variant: "destructive",
137+
});
65138
} finally {
66139
setLoading(false);
67140
}
@@ -79,11 +152,6 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) {
79152
if (response?.error) {
80153
setError(`Can't sign you in at this time`);
81154
} else {
82-
// toast({
83-
// title: TOAST_TITLE_SUCCESS,
84-
// description: LOGIN_SUCCESS,
85-
// });
86-
// router.replace(redirectTo || "/dashboard/my-content");
87155
window.location.href = redirectTo || "/dashboard/my-content";
88156
}
89157
} finally {
@@ -99,7 +167,8 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) {
99167
{error && (
100168
<div
101169
style={{
102-
color: theme?.theme?.colors?.error,
170+
color: theme?.theme?.colors?.light
171+
?.destructive,
103172
}}
104173
className="flex items-center gap-2 mb-4"
105174
>
@@ -218,6 +287,7 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) {
218287
</div>
219288
</div>
220289
</div>
290+
<RecaptchaScriptLoader />
221291
</Section>
222292
);
223293
}

apps/web/app/(with-contexts)/layout.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export default async function Layout({
1919
const config: ServerConfig = {
2020
turnstileSiteKey: process.env.TURNSTILE_SITE_KEY || "",
2121
queueServer: process.env.QUEUE_SERVER || "",
22+
recaptchaSiteKey: process.env.RECAPTCHA_SITE_KEY || "",
2223
};
2324

2425
return (

apps/web/app/api/config/route.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ export const dynamic = "force-dynamic";
44

55
export async function GET(req: NextRequest) {
66
return Response.json(
7-
{ turnstileSiteKey: process.env.TURNSTILE_SITE_KEY },
7+
{
8+
turnstileSiteKey: process.env.TURNSTILE_SITE_KEY,
9+
recaptchaSiteKey: process.env.RECAPTCHA_SITE_KEY || "",
10+
},
811
{ status: 200 },
912
);
1013
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
3+
export async function POST(request: NextRequest) {
4+
const secretKey = process.env.RECAPTCHA_SECRET_KEY;
5+
if (!secretKey) {
6+
console.error("reCAPTCHA secret key not found.");
7+
return NextResponse.json(
8+
{ error: "Internal server error" },
9+
{ status: 500 },
10+
);
11+
}
12+
13+
let requestBody;
14+
try {
15+
requestBody = await request.json();
16+
} catch (error) {
17+
return NextResponse.json(
18+
{ error: "Invalid request body" },
19+
{ status: 400 },
20+
);
21+
}
22+
23+
const { token } = requestBody;
24+
25+
if (!token) {
26+
return NextResponse.json(
27+
{ error: "reCAPTCHA token not found" },
28+
{ status: 400 },
29+
);
30+
}
31+
32+
const formData = `secret=${secretKey}&response=${token}`;
33+
34+
try {
35+
const response = await fetch(
36+
"https://www.google.com/recaptcha/api/siteverify",
37+
{
38+
method: "POST",
39+
headers: {
40+
"Content-Type": "application/x-www-form-urlencoded",
41+
},
42+
body: formData,
43+
},
44+
);
45+
46+
if (!response.ok) {
47+
console.error("Failed to verify reCAPTCHA token with Google");
48+
return NextResponse.json(
49+
{ error: "Failed to verify reCAPTCHA token" },
50+
{ status: 500 },
51+
);
52+
}
53+
54+
const googleResponse = await response.json();
55+
return NextResponse.json({
56+
success: googleResponse.success,
57+
score: googleResponse.score,
58+
action: googleResponse.action,
59+
challenge_ts: googleResponse.challenge_ts,
60+
hostname: googleResponse.hostname,
61+
"error-codes": googleResponse["error-codes"],
62+
});
63+
} catch (error) {
64+
console.error("Error verifying reCAPTCHA token:", error);
65+
return NextResponse.json(
66+
{ error: "Error verifying reCAPTCHA token" },
67+
{ status: 500 },
68+
);
69+
}
70+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"use client";
2+
3+
import { useContext } from "react";
4+
import Script from "next/script";
5+
import { ServerConfigContext } from "@components/contexts";
6+
7+
const RecaptchaScriptLoader = () => {
8+
const { recaptchaSiteKey } = useContext(ServerConfigContext);
9+
10+
if (recaptchaSiteKey) {
11+
return (
12+
<Script
13+
src={`https://www.google.com/recaptcha/api.js?render=${recaptchaSiteKey}`}
14+
strategy="afterInteractive"
15+
async
16+
defer
17+
/>
18+
);
19+
}
20+
21+
return null;
22+
};
23+
24+
export default RecaptchaScriptLoader;

apps/web/hooks/use-recaptcha.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { useCallback, useContext } from "react";
2+
import { ServerConfigContext } from "@components/contexts";
3+
4+
/**
5+
* Custom hook for Google reCAPTCHA v3.
6+
* It uses ServerConfigContext to get the reCAPTCHA site key.
7+
*
8+
* @returns {object} An object containing the `executeRecaptcha` function.
9+
*/
10+
export const useRecaptcha = () => {
11+
const serverConfig = useContext(ServerConfigContext);
12+
const recaptchaSiteKey = serverConfig?.recaptchaSiteKey;
13+
14+
const executeRecaptcha = useCallback(
15+
async (action: string): Promise<string | null> => {
16+
if (!recaptchaSiteKey) {
17+
console.error(
18+
"reCAPTCHA site key not found in ServerConfigContext.",
19+
);
20+
return null;
21+
}
22+
23+
if (
24+
typeof window !== "undefined" &&
25+
window.grecaptcha &&
26+
window.grecaptcha.ready
27+
) {
28+
return new Promise((resolve) => {
29+
window.grecaptcha.ready(async () => {
30+
if (!recaptchaSiteKey) {
31+
// Double check, though already checked above
32+
console.error(
33+
"reCAPTCHA site key became unavailable before execution.",
34+
);
35+
resolve(null);
36+
return;
37+
}
38+
const token = await window.grecaptcha.execute(
39+
recaptchaSiteKey,
40+
{ action },
41+
);
42+
resolve(token);
43+
});
44+
});
45+
} else {
46+
console.error(
47+
"reCAPTCHA (window.grecaptcha) not available. Ensure the script is loaded.",
48+
);
49+
return null;
50+
}
51+
},
52+
[recaptchaSiteKey], // Dependency array includes recaptchaSiteKey
53+
);
54+
55+
return { executeRecaptcha };
56+
};

deployment/docker/docker-compose.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,11 @@ services:
5757
# checking the logs for the API key.
5858
# - MEDIALIT_APIKEY=${MEDIALIT_APIKEY}
5959
# - MEDIALIT_SERVER=http://medialit
60-
60+
#
61+
# Google reCAPTCHA v3 is used to prevent abuse of the login functionality.
62+
# Uncomment the following lines to use reCAPTCHA.
63+
# - RECAPTCHA_SITE_KEY=${RECAPTCHA_SITE_KEY}
64+
# - RECAPTCHA_SECRET_KEY=${RECAPTCHA_SECRET_KEY}
6165
expose:
6266
- "${PORT:-80}"
6367

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export interface ServerConfig {
22
turnstileSiteKey: string;
33
queueServer: string;
4+
recaptchaSiteKey?: string;
45
}

0 commit comments

Comments
 (0)