Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 84 additions & 14 deletions apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { ThemeContext } from "@components/contexts";
import { ServerConfigContext, ThemeContext } from "@components/contexts";
import {
Button,
Caption,
Expand Down Expand Up @@ -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);
Expand All @@ -42,26 +43,98 @@ 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) {
setShowCode(true);
} 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);
}
Expand All @@ -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 {
Expand All @@ -99,7 +167,8 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) {
{error && (
<div
style={{
color: theme?.theme?.colors?.error,
color: theme?.theme?.colors?.light
?.destructive,
}}
className="flex items-center gap-2 mb-4"
>
Expand Down Expand Up @@ -218,6 +287,7 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) {
</div>
</div>
</div>
<RecaptchaScriptLoader />
</Section>
);
}
1 change: 1 addition & 0 deletions apps/web/app/(with-contexts)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
5 changes: 4 additions & 1 deletion apps/web/app/api/config/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
);
}
70 changes: 70 additions & 0 deletions apps/web/app/api/recaptcha/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
);
}
}
24 changes: 24 additions & 0 deletions apps/web/components/recaptcha-script-loader.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Script
src={`https://www.google.com/recaptcha/api.js?render=${recaptchaSiteKey}`}
strategy="afterInteractive"
async
defer
/>
);
}

return null;
};

export default RecaptchaScriptLoader;
56 changes: 56 additions & 0 deletions apps/web/hooks/use-recaptcha.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> => {
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 };
};
6 changes: 5 additions & 1 deletion deployment/docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}"

Expand Down
1 change: 1 addition & 0 deletions packages/common-models/src/server-config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export interface ServerConfig {
turnstileSiteKey: string;
queueServer: string;
recaptchaSiteKey?: string;
}
Loading