Skip to content

Commit 717da80

Browse files
authored
Add reCAPTCHA validation (#43)
* add reCAPTCHA validation and profile link checks in user profile routes * URL Validation * Add Security Headers * remove * add goomgle * Add reCAPTCHA validation for email and Discord username availability checks
1 parent 06e0019 commit 717da80

2 files changed

Lines changed: 96 additions & 4 deletions

File tree

app/api/registration/route.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,26 @@ export async function GET(request: Request) {
604604
const { searchParams } = new URL(request.url);
605605
const email = searchParams.get("email");
606606
const discord_username = searchParams.get("discord_username");
607+
const recaptcha_token = searchParams.get("recaptcha_token");
608+
609+
// reCAPTCHA v3 — fail closed (rejects when the token is missing) and check
610+
// the score + action before any DB lookups. GET carries the token as a query
611+
// param since there is no request body.
612+
const captcha = await verifyRecaptcha(recaptcha_token, "check_registration");
613+
if (!captcha.ok) {
614+
console.warn(
615+
"[registration:check] reCAPTCHA rejected:",
616+
captcha.reason,
617+
captcha.score,
618+
);
619+
return NextResponse.json(
620+
{
621+
message: "reCAPTCHA validation failed",
622+
error: "Security check failed. Please try again.",
623+
},
624+
{ status: 400 },
625+
);
626+
}
607627

608628
if (!email && !discord_username) {
609629
return NextResponse.json(

components/registration/registration-container.tsx

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import { Card } from "./card";
4242
import { StickyAlert } from "./sticky-alert";
4343
import { Spinner } from "@/components/ui/spinner";
4444
import { Modal } from "./modal";
45+
import { API_ENDPOINTS } from "@/lib/api-config";
4546

4647
import { HudFrame } from "./hud-frame";
4748
// PRODUCTION MODE - Debug features disabled
@@ -157,6 +158,9 @@ export function RegistrationContainer({
157158
// Errors
158159
const [errors, setErrors] = useState<Record<string, string>>({});
159160
const [isSubmitting, setIsSubmitting] = useState(false);
161+
// True while a reCAPTCHA-guarded availability check (email / Discord) is in
162+
// flight, so the "Continue" button can show progress and block double-clicks.
163+
const [checkingAvailability, setCheckingAvailability] = useState(false);
160164

161165
// Google SSO. authMethod "google" means the user authenticated via the Google
162166
// popup; we then collect the remaining profile fields, lock the name, and skip
@@ -596,7 +600,25 @@ export function RegistrationContainer({
596600
return "pending";
597601
};
598602

599-
const goNext = () => {
603+
const isFieldTaken = async (
604+
field: "email" | "discord_username",
605+
value: string,
606+
): Promise<boolean> => {
607+
try {
608+
const token = await executeRecaptcha("check_registration");
609+
const params = new URLSearchParams({ [field]: value });
610+
if (token) params.set("recaptcha_token", token);
611+
const res = await fetch(`${API_ENDPOINTS.register}?${params.toString()}`);
612+
if (!res.ok) return false;
613+
const data = await res.json();
614+
return data?.exists === true;
615+
} catch (error) {
616+
console.error("[registration] availability check failed:", error);
617+
return false;
618+
}
619+
};
620+
621+
const goNext = async () => {
600622
const stepId = STEPS[currentStepIndex].id;
601623
const stepErrors = validateStep(stepId);
602624

@@ -610,6 +632,42 @@ export function RegistrationContainer({
610632
return;
611633
}
612634

635+
const checks: Array<{
636+
field: "email" | "discord_username";
637+
value: string;
638+
message: string;
639+
}> = [];
640+
if (stepId === "account" && authMethod === "email") {
641+
checks.push({
642+
field: "email",
643+
value: registerData.email.trim(),
644+
message: "This email is already registered.",
645+
});
646+
}
647+
if (stepId === "identity") {
648+
checks.push({
649+
field: "discord_username",
650+
value: registerData.discord_username.trim(),
651+
message: "This Discord username is already registered.",
652+
});
653+
}
654+
655+
if (checks.length > 0) {
656+
setCheckingAvailability(true);
657+
try {
658+
for (const check of checks) {
659+
if (await isFieldTaken(check.field, check.value)) {
660+
setErrors((prev) => ({ ...prev, [check.field]: check.message }));
661+
setAlert({ type: "error", message: check.message });
662+
setTimeout(() => setAlert(null), 3000);
663+
return;
664+
}
665+
}
666+
} finally {
667+
setCheckingAvailability(false);
668+
}
669+
}
670+
613671
setAlert(null);
614672
if (currentStepIndex < STEPS.length - 1) {
615673
setCurrentStepIndex((i) => i + 1);
@@ -1869,9 +1927,23 @@ export function RegistrationContainer({
18691927
{STEPS.length - currentStepIndex - 1} step
18701928
{STEPS.length - currentStepIndex - 1 === 1 ? "" : "s"} to go
18711929
</div>
1872-
<Button type="button" variant="primary" onClick={goNext}>
1873-
Continue
1874-
<ChevronRight className="w-4 h-4 ml-1.5" />
1930+
<Button
1931+
type="button"
1932+
variant="primary"
1933+
onClick={goNext}
1934+
disabled={checkingAvailability}
1935+
>
1936+
{checkingAvailability ? (
1937+
<>
1938+
<Spinner size="sm" className="mr-2" />
1939+
Checking…
1940+
</>
1941+
) : (
1942+
<>
1943+
Continue
1944+
<ChevronRight className="w-4 h-4 ml-1.5" />
1945+
</>
1946+
)}
18751947
</Button>
18761948
</div>
18771949
)}

0 commit comments

Comments
 (0)