From 231ed083be7b3f5026a9e175ba2a56a74c2e0ca1 Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Tue, 26 May 2026 12:35:06 +0500 Subject: [PATCH] Add anti-bot registration protection - Custom /api/register route: honeypot, disposable email blocking, Cloudflare Turnstile verification - Register form: honeypot hidden field, Turnstile widget (loads when NEXT_PUBLIC_TURNSTILE_SITE_KEY set) - Install disposable-email-domains package - Add NEXT_PUBLIC_TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY env vars to .env.example Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 4 + package-lock.json | 7 ++ package.json | 1 + src/app/(auth)/register/register-form.tsx | 103 ++++++++++++++++++++-- src/app/api/register/route.ts | 76 ++++++++++++++++ 5 files changed, 183 insertions(+), 8 deletions(-) create mode 100644 src/app/api/register/route.ts diff --git a/.env.example b/.env.example index 5eb44c0..57587c8 100644 --- a/.env.example +++ b/.env.example @@ -12,5 +12,9 @@ S3_ACCESS_KEY="" S3_SECRET_KEY="" S3_REGION="eu-central" +# Cloudflare Turnstile (anti-bot защита на регистрации) +NEXT_PUBLIC_TURNSTILE_SITE_KEY="" +TURNSTILE_SECRET_KEY="" + # Kinescope (добавить при получении платного плана) # KINESCOPE_API_KEY="" diff --git a/package-lock.json b/package-lock.json index d98943a..ef7abdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "better-auth": "^1.6.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "disposable-email-domains": "^1.0.62", "gray-matter": "^4.0.3", "iconv-lite": "^0.7.2", "lucide-react": "^1.7.0", @@ -6302,6 +6303,12 @@ "node": ">=0.3.1" } }, + "node_modules/disposable-email-domains": { + "version": "1.0.62", + "resolved": "https://registry.npmjs.org/disposable-email-domains/-/disposable-email-domains-1.0.62.tgz", + "integrity": "sha512-LBQvhRw7mznQTPoyZbsmYeNOZt1pN5aCsx4BAU/3siVFuiM9f2oyKzUaB8v1jbxFjE3aYqYiMo63kAL4pHgfWQ==", + "license": "MIT" + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", diff --git a/package.json b/package.json index 524e7cd..2fe6695 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "better-auth": "^1.6.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "disposable-email-domains": "^1.0.62", "gray-matter": "^4.0.3", "iconv-lite": "^0.7.2", "lucide-react": "^1.7.0", diff --git a/src/app/(auth)/register/register-form.tsx b/src/app/(auth)/register/register-form.tsx index b7e3247..648c8b5 100644 --- a/src/app/(auth)/register/register-form.tsx +++ b/src/app/(auth)/register/register-form.tsx @@ -1,8 +1,24 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect, useRef } from "react"; import Link from "next/link"; -import { signUp } from "@/lib/auth-client"; + +declare global { + interface Window { + turnstile?: { + render: ( + container: HTMLElement, + options: { + sitekey: string; + callback: (token: string) => void; + "expired-callback"?: () => void; + "error-callback"?: () => void; + } + ) => string; + reset: (widgetId: string) => void; + }; + } +} interface Props { showTermsCheckbox: boolean; @@ -15,11 +31,43 @@ export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, of const [name, setName] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); + const [honeypot, setHoneypot] = useState(""); const [termsAccepted, setTermsAccepted] = useState(false); + const [turnstileToken, setTurnstileToken] = useState(null); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); const [success, setSuccess] = useState(false); + const turnstileContainerRef = useRef(null); + const turnstileWidgetId = useRef(null); + + const siteKey = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY; + + useEffect(() => { + if (!siteKey || !turnstileContainerRef.current) return; + + const script = document.createElement("script"); + script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js"; + script.async = true; + script.defer = true; + document.head.appendChild(script); + + script.onload = () => { + if (!turnstileContainerRef.current || !window.turnstile) return; + turnstileWidgetId.current = window.turnstile.render(turnstileContainerRef.current, { + sitekey: siteKey, + callback: (token) => setTurnstileToken(token), + "expired-callback": () => setTurnstileToken(null), + "error-callback": () => setTurnstileToken(null), + }); + }; + + return () => { + if (document.head.contains(script)) document.head.removeChild(script); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const inputStyle = { border: "2px solid var(--border)", background: "var(--background)", @@ -42,18 +90,43 @@ export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, of setError("Необходимо принять условия для продолжения"); return; } + if (siteKey && !turnstileToken) { + setError("Пожалуйста, подтвердите, что вы не робот"); + return; + } setError(""); setLoading(true); - const result = await signUp.email({ name, email, password }); + try { + const res = await fetch("/api/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name, + email, + password, + website: honeypot, + cfTurnstileResponse: turnstileToken, + }), + }); - if (result.error) { - setError(result.error.message ?? "Ошибка регистрации"); - setLoading(false); - return; + const data = await res.json() as { message?: string }; + + if (!res.ok) { + setError(data.message ?? "Ошибка регистрации"); + if (turnstileWidgetId.current) { + window.turnstile?.reset(turnstileWidgetId.current); + setTurnstileToken(null); + } + setLoading(false); + return; + } + + setSuccess(true); + } catch { + setError("Ошибка соединения. Попробуйте ещё раз."); } - setSuccess(true); setLoading(false); } @@ -74,6 +147,18 @@ export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, of return (
+ {/* Honeypot — hidden from humans, visible to bots */} + +
+ {siteKey &&
} + {showTermsCheckbox && (