diff --git a/apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx b/apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx
index 119224591..fad10c976 100644
--- a/apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx
+++ b/apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx
@@ -1,6 +1,6 @@
"use client";
-import { ThemeContext } from "@components/contexts";
+import { ServerConfigContext, ThemeContext } from "@components/contexts";
import {
Button,
Caption,
@@ -32,7 +32,8 @@ import {
} from "@/ui-config/strings";
import Link from "next/link";
import { TriangleAlert } from "lucide-react";
-import { useRouter } from "next/navigation";
+import { useRecaptcha } from "@/hooks/use-recaptcha";
+import RecaptchaScriptLoader from "@/components/recaptcha-script-loader";
export default function LoginForm({ redirectTo }: { redirectTo?: string }) {
const { theme } = useContext(ThemeContext);
@@ -42,15 +43,80 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) {
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const { toast } = useToast();
- const router = useRouter();
+ const serverConfig = useContext(ServerConfigContext);
+ const { executeRecaptcha } = useRecaptcha();
const requestCode = async function (e: FormEvent) {
e.preventDefault();
- const url = `/api/auth/code/generate?email=${encodeURIComponent(
- email,
- )}`;
+ setLoading(true);
+ setError("");
+
+ if (serverConfig.recaptchaSiteKey) {
+ if (!executeRecaptcha) {
+ toast({
+ title: TOAST_TITLE_ERROR,
+ description:
+ "reCAPTCHA service not available. Please try again later.",
+ variant: "destructive",
+ });
+ setLoading(false);
+ return;
+ }
+
+ const recaptchaToken = await executeRecaptcha("login_code_request");
+ if (!recaptchaToken) {
+ toast({
+ title: TOAST_TITLE_ERROR,
+ description:
+ "reCAPTCHA validation failed. Please try again.",
+ variant: "destructive",
+ });
+ setLoading(false);
+ return;
+ }
+ try {
+ const recaptchaVerificationResponse = await fetch(
+ "/api/recaptcha",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: recaptchaToken }),
+ },
+ );
+
+ const recaptchaData =
+ await recaptchaVerificationResponse.json();
+
+ if (
+ !recaptchaVerificationResponse.ok ||
+ !recaptchaData.success ||
+ (recaptchaData.score && recaptchaData.score < 0.5)
+ ) {
+ toast({
+ title: TOAST_TITLE_ERROR,
+ description: `reCAPTCHA verification failed. ${recaptchaData.score ? `Score: ${recaptchaData.score.toFixed(2)}.` : ""} Please try again.`,
+ variant: "destructive",
+ });
+ setLoading(false);
+ return;
+ }
+ } catch (err) {
+ console.error("Error during reCAPTCHA verification:", err);
+ toast({
+ title: TOAST_TITLE_ERROR,
+ description:
+ "reCAPTCHA verification failed. Please try again.",
+ variant: "destructive",
+ });
+ setLoading(false);
+ return;
+ }
+ }
+
try {
- setLoading(true);
+ const url = `/api/auth/code/generate?email=${encodeURIComponent(
+ email,
+ )}`;
const response = await fetch(url);
const resp = await response.json();
if (response.ok) {
@@ -58,10 +124,17 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) {
} else {
toast({
title: TOAST_TITLE_ERROR,
- description: resp.error,
+ description: resp.error || "Failed to request code.",
variant: "destructive",
});
}
+ } catch (err) {
+ console.error("Error during requestCode:", err);
+ toast({
+ title: TOAST_TITLE_ERROR,
+ description: "An unexpected error occurred. Please try again.",
+ variant: "destructive",
+ });
} finally {
setLoading(false);
}
@@ -79,11 +152,6 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) {
if (response?.error) {
setError(`Can't sign you in at this time`);
} else {
- // toast({
- // title: TOAST_TITLE_SUCCESS,
- // description: LOGIN_SUCCESS,
- // });
- // router.replace(redirectTo || "/dashboard/my-content");
window.location.href = redirectTo || "/dashboard/my-content";
}
} finally {
@@ -99,7 +167,8 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) {
{error && (
@@ -218,6 +287,7 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) {
+
);
}
diff --git a/apps/web/app/(with-contexts)/layout.tsx b/apps/web/app/(with-contexts)/layout.tsx
index 0affcbba3..351248b63 100644
--- a/apps/web/app/(with-contexts)/layout.tsx
+++ b/apps/web/app/(with-contexts)/layout.tsx
@@ -19,6 +19,7 @@ export default async function Layout({
const config: ServerConfig = {
turnstileSiteKey: process.env.TURNSTILE_SITE_KEY || "",
queueServer: process.env.QUEUE_SERVER || "",
+ recaptchaSiteKey: process.env.RECAPTCHA_SITE_KEY || "",
};
return (
diff --git a/apps/web/app/api/config/route.ts b/apps/web/app/api/config/route.ts
index 1f6f0ab7f..38ec7c716 100644
--- a/apps/web/app/api/config/route.ts
+++ b/apps/web/app/api/config/route.ts
@@ -4,7 +4,10 @@ export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
return Response.json(
- { turnstileSiteKey: process.env.TURNSTILE_SITE_KEY },
+ {
+ turnstileSiteKey: process.env.TURNSTILE_SITE_KEY,
+ recaptchaSiteKey: process.env.RECAPTCHA_SITE_KEY || "",
+ },
{ status: 200 },
);
}
diff --git a/apps/web/app/api/recaptcha/route.ts b/apps/web/app/api/recaptcha/route.ts
new file mode 100644
index 000000000..0b8943535
--- /dev/null
+++ b/apps/web/app/api/recaptcha/route.ts
@@ -0,0 +1,70 @@
+import { NextRequest, NextResponse } from "next/server";
+
+export async function POST(request: NextRequest) {
+ const secretKey = process.env.RECAPTCHA_SECRET_KEY;
+ if (!secretKey) {
+ console.error("reCAPTCHA secret key not found.");
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 },
+ );
+ }
+
+ let requestBody;
+ try {
+ requestBody = await request.json();
+ } catch (error) {
+ return NextResponse.json(
+ { error: "Invalid request body" },
+ { status: 400 },
+ );
+ }
+
+ const { token } = requestBody;
+
+ if (!token) {
+ return NextResponse.json(
+ { error: "reCAPTCHA token not found" },
+ { status: 400 },
+ );
+ }
+
+ const formData = `secret=${secretKey}&response=${token}`;
+
+ try {
+ const response = await fetch(
+ "https://www.google.com/recaptcha/api/siteverify",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ body: formData,
+ },
+ );
+
+ if (!response.ok) {
+ console.error("Failed to verify reCAPTCHA token with Google");
+ return NextResponse.json(
+ { error: "Failed to verify reCAPTCHA token" },
+ { status: 500 },
+ );
+ }
+
+ const googleResponse = await response.json();
+ return NextResponse.json({
+ success: googleResponse.success,
+ score: googleResponse.score,
+ action: googleResponse.action,
+ challenge_ts: googleResponse.challenge_ts,
+ hostname: googleResponse.hostname,
+ "error-codes": googleResponse["error-codes"],
+ });
+ } catch (error) {
+ console.error("Error verifying reCAPTCHA token:", error);
+ return NextResponse.json(
+ { error: "Error verifying reCAPTCHA token" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/apps/web/components/recaptcha-script-loader.tsx b/apps/web/components/recaptcha-script-loader.tsx
new file mode 100644
index 000000000..9d812d9bc
--- /dev/null
+++ b/apps/web/components/recaptcha-script-loader.tsx
@@ -0,0 +1,24 @@
+"use client";
+
+import { useContext } from "react";
+import Script from "next/script";
+import { ServerConfigContext } from "@components/contexts";
+
+const RecaptchaScriptLoader = () => {
+ const { recaptchaSiteKey } = useContext(ServerConfigContext);
+
+ if (recaptchaSiteKey) {
+ return (
+
+ );
+ }
+
+ return null;
+};
+
+export default RecaptchaScriptLoader;
diff --git a/apps/web/hooks/use-recaptcha.ts b/apps/web/hooks/use-recaptcha.ts
new file mode 100644
index 000000000..996085d5d
--- /dev/null
+++ b/apps/web/hooks/use-recaptcha.ts
@@ -0,0 +1,56 @@
+import { useCallback, useContext } from "react";
+import { ServerConfigContext } from "@components/contexts";
+
+/**
+ * Custom hook for Google reCAPTCHA v3.
+ * It uses ServerConfigContext to get the reCAPTCHA site key.
+ *
+ * @returns {object} An object containing the `executeRecaptcha` function.
+ */
+export const useRecaptcha = () => {
+ const serverConfig = useContext(ServerConfigContext);
+ const recaptchaSiteKey = serverConfig?.recaptchaSiteKey;
+
+ const executeRecaptcha = useCallback(
+ async (action: string): Promise => {
+ if (!recaptchaSiteKey) {
+ console.error(
+ "reCAPTCHA site key not found in ServerConfigContext.",
+ );
+ return null;
+ }
+
+ if (
+ typeof window !== "undefined" &&
+ window.grecaptcha &&
+ window.grecaptcha.ready
+ ) {
+ return new Promise((resolve) => {
+ window.grecaptcha.ready(async () => {
+ if (!recaptchaSiteKey) {
+ // Double check, though already checked above
+ console.error(
+ "reCAPTCHA site key became unavailable before execution.",
+ );
+ resolve(null);
+ return;
+ }
+ const token = await window.grecaptcha.execute(
+ recaptchaSiteKey,
+ { action },
+ );
+ resolve(token);
+ });
+ });
+ } else {
+ console.error(
+ "reCAPTCHA (window.grecaptcha) not available. Ensure the script is loaded.",
+ );
+ return null;
+ }
+ },
+ [recaptchaSiteKey], // Dependency array includes recaptchaSiteKey
+ );
+
+ return { executeRecaptcha };
+};
diff --git a/deployment/docker/docker-compose.yml b/deployment/docker/docker-compose.yml
index 4a7b976e4..9e10fa7e6 100644
--- a/deployment/docker/docker-compose.yml
+++ b/deployment/docker/docker-compose.yml
@@ -57,7 +57,11 @@ services:
# checking the logs for the API key.
# - MEDIALIT_APIKEY=${MEDIALIT_APIKEY}
# - MEDIALIT_SERVER=http://medialit
-
+ #
+ # Google reCAPTCHA v3 is used to prevent abuse of the login functionality.
+ # Uncomment the following lines to use reCAPTCHA.
+ # - RECAPTCHA_SITE_KEY=${RECAPTCHA_SITE_KEY}
+ # - RECAPTCHA_SECRET_KEY=${RECAPTCHA_SECRET_KEY}
expose:
- "${PORT:-80}"
diff --git a/packages/common-models/src/server-config.ts b/packages/common-models/src/server-config.ts
index 723df3c59..174ba441b 100644
--- a/packages/common-models/src/server-config.ts
+++ b/packages/common-models/src/server-config.ts
@@ -1,4 +1,5 @@
export interface ServerConfig {
turnstileSiteKey: string;
queueServer: string;
+ recaptchaSiteKey?: string;
}