Skip to content

Commit 423e035

Browse files
committed
feat: integrate reCAPTCHA v3
1 parent ecc54ba commit 423e035

13 files changed

Lines changed: 332 additions & 9 deletions

File tree

app/admin/register/page.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,21 @@
22
import { useState, useEffect } from "react";
33
import { useRouter } from "next/navigation";
44
import { useAuth } from "@/hooks/use-auth";
5+
import { useRecaptcha } from "@/hooks/use-recaptcha";
56
import { useToast } from "@/hooks/use-toast";
67
import { FormInput } from "@/components/registration/form-input";
78
import { Button } from "@/components/registration/button";
89
import { Card } from "@/components/registration/card";
910
import { StickyAlert } from "@/components/registration/sticky-alert";
11+
import { RecaptchaNotice } from "@/components/registration/recaptcha-notice";
1012
import { DotPattern } from "@/components/registration/dot-pattern";
1113
import { Spinner } from "@/components/ui/spinner";
1214
import { UserPlus, LogIn } from "lucide-react";
1315
import Link from "next/link";
1416

1517
export default function AdminRegisterPage() {
1618
const { isAuthenticated, isLoading } = useAuth();
19+
const { executeRecaptcha } = useRecaptcha();
1720
const { toast } = useToast();
1821
const router = useRouter();
1922

@@ -157,6 +160,7 @@ export default function AdminRegisterPage() {
157160
setAlert(null);
158161

159162
try {
163+
const recaptchaToken = await executeRecaptcha("admin_register");
160164
const response = await fetch('/api/admin/register', {
161165
method: 'POST',
162166
headers: {
@@ -167,6 +171,7 @@ export default function AdminRegisterPage() {
167171
email: formData.email.trim(),
168172
password: formData.password,
169173
adminCode: formData.adminCode.trim(),
174+
recaptcha_token: recaptchaToken,
170175
}),
171176
});
172177

@@ -451,6 +456,7 @@ export default function AdminRegisterPage() {
451456
<LogIn className="w-[16px] h-[16px]" />
452457
<span>Login</span>
453458
</Link>
459+
<RecaptchaNotice className="mt-[16px]" />
454460
</div>
455461
</Card>
456462
</div>

app/api/admin/register/route.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createUserWithEmailAndPassword } from "firebase/auth";
44
import dbConnect from "@/lib/db";
55
import User, { IUser } from "@/models/User";
66
import { getAuth } from "@/lib/firebase-admin";
7+
import { verifyRecaptcha } from "@/lib/recaptcha";
78

89
export const dynamic = 'force-dynamic';
910
export const runtime = 'nodejs';
@@ -21,7 +22,21 @@ const validatePassword = (password: string) => {
2122
export async function POST(request: Request) {
2223
try {
2324
const body = await request.json();
24-
const { name, email, password, adminCode } = body;
25+
const { name, email, password, adminCode, recaptcha_token } = body;
26+
27+
// reCAPTCHA v3 background score check — reject likely-bot traffic.
28+
const captcha = await verifyRecaptcha(recaptcha_token, "admin_register");
29+
if (!captcha.ok) {
30+
console.warn("[admin/register] reCAPTCHA rejected:", captcha.reason, captcha.score);
31+
return NextResponse.json(
32+
{
33+
success: false,
34+
message: "Security check failed. Please try again.",
35+
error: { code: "recaptcha_failed", message: "reCAPTCHA verification failed" },
36+
},
37+
{ status: 400 }
38+
);
39+
}
2540

2641
const errors: Record<string, string> = {};
2742

app/api/evaluator/register/route.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Evaluator from "@/models/Evaluator";
55
import User from "@/models/User";
66
import { getAuth } from "@/lib/firebase-admin";
77
import { checkRateLimit } from "@/lib/rate-limit";
8+
import { verifyRecaptcha } from "@/lib/recaptcha";
89

910
export const dynamic = 'force-dynamic';
1011

@@ -54,7 +55,20 @@ export async function POST(request: NextRequest) {
5455
const uid = decodedToken.uid;
5556
const email = decodedToken.email || "";
5657
const body = await request.json();
57-
const { name, evaluatorCode } = body;
58+
const { name, evaluatorCode, recaptcha_token } = body;
59+
60+
// reCAPTCHA v3 background score check. The Firebase user was created
61+
// client-side before this call, so clean it up if the check fails.
62+
const captcha = await verifyRecaptcha(recaptcha_token, "evaluator_register");
63+
if (!captcha.ok) {
64+
console.warn("[evaluator/register] reCAPTCHA rejected:", captcha.reason, captcha.score);
65+
try {
66+
await getAuth().deleteUser(uid);
67+
} catch (deleteError) {
68+
console.error(`Failed to delete user ${uid} after reCAPTCHA failure:`, deleteError);
69+
}
70+
return createErrorResponse("Security check failed. Please try again.", "RECAPTCHA_FAILED", 400);
71+
}
5872

5973
if (!name) {
6074
return createErrorResponse("Name is required", "VALIDATION_ERROR", 400);

app/api/user/login/route.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { FirebaseError } from "firebase/app";
55
import dbConnect from "@/lib/db";
66
import User from "@/models/User";
77
import { getAuth } from "@/lib/firebase-admin";
8+
import { verifyRecaptcha } from "@/lib/recaptcha";
89

910
const ADMIN_EMAIL_DOMAIN = process.env.ADMIN_EMAIL_DOMAIN;
1011
const SECRET_CODE = process.env.SECRET_CODE;
@@ -27,7 +28,7 @@ async function authenticateUser(email: string, password: string, isAdminAttempt:
2728

2829
export async function POST(request: Request) {
2930
try {
30-
const { email, password } = await request.json();
31+
const { email, password, recaptcha_token } = await request.json();
3132

3233
if (!email || !password) {
3334
return NextResponse.json(
@@ -36,6 +37,20 @@ export async function POST(request: Request) {
3637
);
3738
}
3839

40+
// reCAPTCHA v3 background score check — reject likely-bot traffic.
41+
const captcha = await verifyRecaptcha(recaptcha_token, "login");
42+
if (!captcha.ok) {
43+
console.warn("[login] reCAPTCHA rejected:", captcha.reason, captcha.score);
44+
return NextResponse.json(
45+
{
46+
success: false,
47+
message: "Security check failed. Please try again.",
48+
error: { code: "recaptcha_failed", message: "reCAPTCHA verification failed" },
49+
},
50+
{ status: 400 }
51+
);
52+
}
53+
3954
const isAdminAttempt = ADMIN_EMAIL_DOMAIN && SECRET_CODE &&
4055
email.endsWith(ADMIN_EMAIL_DOMAIN) &&
4156
password === SECRET_CODE;

app/api/user/register/route.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
import { cloudinaryV2 } from "@/c";
88
import dbConnect from "@/lib/db";
99
import User, { IUser } from "@/models/User";
10+
import { verifyRecaptcha } from "@/lib/recaptcha";
1011

1112
// Configure route
1213
export const dynamic = "force-dynamic";
@@ -85,6 +86,22 @@ export async function POST(request: Request) {
8586

8687
const formData = await request.formData();
8788

89+
// reCAPTCHA v3 background score check — reject likely-bot registrations
90+
// before doing any Firebase/Cloudinary/DB work.
91+
const recaptchaToken = formData.get("recaptcha_token") as string | null;
92+
const captcha = await verifyRecaptcha(recaptchaToken, "register");
93+
if (!captcha.ok) {
94+
console.warn("[register] reCAPTCHA rejected:", captcha.reason, captcha.score);
95+
return NextResponse.json(
96+
{
97+
success: false,
98+
message: "Security check failed. Please try again.",
99+
error: { code: "recaptcha_failed", message: "reCAPTCHA verification failed" },
100+
},
101+
{ status: 400 },
102+
);
103+
}
104+
88105
// Helper to convert File to base64
89106
const fileToBase64 = async (file: File): Promise<string> => {
90107
const arrayBuffer = await file.arrayBuffer();

app/evaluator/register/page.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
import { useState, useEffect } from "react";
44
import { useRouter } from "next/navigation";
55
import { useAuth } from "@/hooks/use-auth";
6+
import { useRecaptcha } from "@/hooks/use-recaptcha";
67
import { FormSection } from "@/components/registration/form-section";
78
import { FormInput } from "@/components/registration/form-input";
89
import { Button } from "@/components/registration/button";
910
import { StickyAlert } from "@/components/registration/sticky-alert";
11+
import { RecaptchaNotice } from "@/components/registration/recaptcha-notice";
1012
import { DotPattern } from "@/components/registration/dot-pattern";
1113
import { Spinner } from "@/components/ui/spinner";
1214
import { ShieldPlus } from "lucide-react";
@@ -16,6 +18,7 @@ import { auth } from "@/Firebase";
1618

1719
export default function EvaluatorRegisterPage() {
1820
const { isAuthenticated, isLoading, user } = useAuth();
21+
const { executeRecaptcha } = useRecaptcha();
1922
const [formData, setFormData] = useState({
2023
name: "",
2124
email: "",
@@ -63,6 +66,9 @@ export default function EvaluatorRegisterPage() {
6366
}
6467

6568
try {
69+
// reCAPTCHA v3 background token — scored server-side, no user interaction.
70+
const recaptchaToken = await executeRecaptcha("evaluator_register");
71+
6672
// Create user in Firebase directly to avoid /api/registration requirements
6773
const userCredential = await createUserWithEmailAndPassword(
6874
auth,
@@ -81,7 +87,8 @@ export default function EvaluatorRegisterPage() {
8187
},
8288
body: JSON.stringify({
8389
name: formData.name,
84-
evaluatorCode: formData.evaluatorCode
90+
evaluatorCode: formData.evaluatorCode,
91+
recaptcha_token: recaptchaToken
8592
})
8693
});
8794

@@ -219,6 +226,8 @@ export default function EvaluatorRegisterPage() {
219226
← Back to Main Registration
220227
</button>
221228
</div>
229+
230+
<RecaptchaNotice />
222231
</form>
223232
</FormSection>
224233
</div>

app/globals.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,3 +404,9 @@ input[type="number"] { -moz-appearance: textfield; }
404404
-webkit-backdrop-filter: blur(8px);
405405
border: 1px solid var(--border-hairline);
406406
}
407+
408+
/* Hide the floating reCAPTCHA v3 badge. Per Google's terms, the required
409+
disclosure is shown inline on each auth form instead (see RecaptchaNotice). */
410+
.grecaptcha-badge {
411+
visibility: hidden;
412+
}

app/login/page.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,19 @@
33
import { useState, useEffect } from "react";
44
import { useRouter } from "next/navigation";
55
import { useAuth } from "@/hooks/use-auth";
6+
import { useRecaptcha } from "@/hooks/use-recaptcha";
67
import { FormSection } from "@/components/registration/form-section";
78
import { FormInput } from "@/components/registration/form-input";
89
import { Button } from "@/components/registration/button";
910
import { StickyAlert } from "@/components/registration/sticky-alert";
11+
import { RecaptchaNotice } from "@/components/registration/recaptcha-notice";
1012
import { DotPattern } from "@/components/registration/dot-pattern";
1113
import { Spinner } from "@/components/ui/spinner";
1214
import { ArrowRight, LogIn } from "lucide-react";
1315

1416
export default function LoginPage() {
1517
const { login, isAuthenticated, isLoading } = useAuth();
18+
const { executeRecaptcha } = useRecaptcha();
1619
const [loginData, setLoginData] = useState({ email: "", password: "" });
1720
const [error, setError] = useState("");
1821
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -27,7 +30,8 @@ export default function LoginPage() {
2730
setIsSubmitting(true);
2831
setError("");
2932
try {
30-
await login(loginData.email, loginData.password);
33+
const recaptchaToken = await executeRecaptcha("login");
34+
await login(loginData.email, loginData.password, recaptchaToken);
3135
router.push("/dashboard");
3236
} catch (err: any) {
3337
setError(err?.message || "Login failed. Please try again.");
@@ -116,6 +120,8 @@ export default function LoginPage() {
116120
Register here
117121
</button>
118122
</div>
123+
124+
<RecaptchaNotice className="pt-1" />
119125
</form>
120126
</FormSection>
121127
</div>

components/providers/auth-provider.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ interface AuthContextType {
4747
// session — without waiting for /api/user/profile to also come back.
4848
firebaseUser: FirebaseUser | null;
4949
loading: boolean;
50-
login: (email: string, password: string) => Promise<void>;
50+
login: (email: string, password: string, recaptchaToken?: string | null) => Promise<void>;
5151
register: (formData: FormData) => Promise<void>;
5252
logout: () => Promise<void>;
5353
refreshUser: () => Promise<void>;
@@ -155,15 +155,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
155155
return () => unsubscribe();
156156
}, []);
157157

158-
const login = useCallback(async (email: string, password: string) => {
158+
const login = useCallback(async (email: string, password: string, recaptchaToken?: string | null) => {
159159
try {
160160
setLoading(true); // Start loading immediately
161161
const response = await fetch(API_ENDPOINTS.login, {
162162
method: 'POST',
163163
headers: {
164164
'Content-Type': 'application/json',
165165
},
166-
body: JSON.stringify({ email, password }),
166+
body: JSON.stringify({ email, password, recaptcha_token: recaptchaToken ?? null }),
167167
});
168168

169169
const data = await response.json();
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* Inline reCAPTCHA disclosure.
3+
*
4+
* The floating reCAPTCHA badge is hidden via CSS (see globals.css). Google's
5+
* terms require that, when the badge is hidden, this disclosure is shown to
6+
* users wherever reCAPTCHA is active. Drop this near each auth form's submit.
7+
*/
8+
export function RecaptchaNotice({ className = "" }: { className?: string }) {
9+
return (
10+
<p
11+
className={`text-[11px] leading-[1.5] text-ink-muted text-center ${className}`}
12+
>
13+
This site is protected by reCAPTCHA and the Google{" "}
14+
<a
15+
href="https://policies.google.com/privacy"
16+
target="_blank"
17+
rel="noopener noreferrer"
18+
className="text-brand/80 hover:text-brand underline underline-offset-2"
19+
>
20+
Privacy Policy
21+
</a>{" "}
22+
and{" "}
23+
<a
24+
href="https://policies.google.com/terms"
25+
target="_blank"
26+
rel="noopener noreferrer"
27+
className="text-brand/80 hover:text-brand underline underline-offset-2"
28+
>
29+
Terms of Service
30+
</a>{" "}
31+
apply.
32+
</p>
33+
);
34+
}

0 commit comments

Comments
 (0)