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 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 12:35:06 +05:00
parent c1c33a0619
commit 231ed083be
5 changed files with 183 additions and 8 deletions
+4
View File
@@ -12,5 +12,9 @@ S3_ACCESS_KEY=""
S3_SECRET_KEY="" S3_SECRET_KEY=""
S3_REGION="eu-central" S3_REGION="eu-central"
# Cloudflare Turnstile (anti-bot защита на регистрации)
NEXT_PUBLIC_TURNSTILE_SITE_KEY=""
TURNSTILE_SECRET_KEY=""
# Kinescope (добавить при получении платного плана) # Kinescope (добавить при получении платного плана)
# KINESCOPE_API_KEY="" # KINESCOPE_API_KEY=""
+7
View File
@@ -28,6 +28,7 @@
"better-auth": "^1.6.0", "better-auth": "^1.6.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"disposable-email-domains": "^1.0.62",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"iconv-lite": "^0.7.2", "iconv-lite": "^0.7.2",
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
@@ -6302,6 +6303,12 @@
"node": ">=0.3.1" "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": { "node_modules/doctrine": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
+1
View File
@@ -33,6 +33,7 @@
"better-auth": "^1.6.0", "better-auth": "^1.6.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"disposable-email-domains": "^1.0.62",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"iconv-lite": "^0.7.2", "iconv-lite": "^0.7.2",
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
+95 -8
View File
@@ -1,8 +1,24 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useEffect, useRef } from "react";
import Link from "next/link"; 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 { interface Props {
showTermsCheckbox: boolean; showTermsCheckbox: boolean;
@@ -15,11 +31,43 @@ export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, of
const [name, setName] = useState(""); const [name, setName] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [honeypot, setHoneypot] = useState("");
const [termsAccepted, setTermsAccepted] = useState(false); const [termsAccepted, setTermsAccepted] = useState(false);
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const turnstileContainerRef = useRef<HTMLDivElement>(null);
const turnstileWidgetId = useRef<string | null>(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 = { const inputStyle = {
border: "2px solid var(--border)", border: "2px solid var(--border)",
background: "var(--background)", background: "var(--background)",
@@ -42,18 +90,43 @@ export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, of
setError("Необходимо принять условия для продолжения"); setError("Необходимо принять условия для продолжения");
return; return;
} }
if (siteKey && !turnstileToken) {
setError("Пожалуйста, подтвердите, что вы не робот");
return;
}
setError(""); setError("");
setLoading(true); 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) { const data = await res.json() as { message?: string };
setError(result.error.message ?? "Ошибка регистрации");
setLoading(false); if (!res.ok) {
return; setError(data.message ?? "Ошибка регистрации");
if (turnstileWidgetId.current) {
window.turnstile?.reset(turnstileWidgetId.current);
setTurnstileToken(null);
}
setLoading(false);
return;
}
setSuccess(true);
} catch {
setError("Ошибка соединения. Попробуйте ещё раз.");
} }
setSuccess(true);
setLoading(false); setLoading(false);
} }
@@ -74,6 +147,18 @@ export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, of
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{/* Honeypot — hidden from humans, visible to bots */}
<div style={{ position: "absolute", left: "-9999px", top: "-9999px" }} aria-hidden="true">
<input
type="text"
name="website"
value={honeypot}
onChange={(e) => setHoneypot(e.target.value)}
tabIndex={-1}
autoComplete="off"
/>
</div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}> <label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
Имя Имя
@@ -121,6 +206,8 @@ export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, of
/> />
</div> </div>
{siteKey && <div ref={turnstileContainerRef} />}
{showTermsCheckbox && ( {showTermsCheckbox && (
<label className="flex items-start gap-2 cursor-pointer"> <label className="flex items-start gap-2 cursor-pointer">
<input <input
+76
View File
@@ -0,0 +1,76 @@
import { NextRequest } from "next/server";
import { auth } from "@/lib/auth";
// eslint-disable-next-line @typescript-eslint/no-require-imports
const disposableDomains = require("disposable-email-domains") as string[];
async function verifyTurnstile(token: string): Promise<boolean> {
try {
const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
secret: process.env.TURNSTILE_SECRET_KEY,
response: token,
}),
});
const data = await res.json() as { success: boolean };
return data.success;
} catch {
return false;
}
}
function jsonError(message: string, status: number) {
return new Response(JSON.stringify({ message }), {
status,
headers: { "Content-Type": "application/json" },
});
}
export async function POST(request: NextRequest) {
let body: Record<string, string>;
try {
body = await request.json() as Record<string, string>;
} catch {
return jsonError("Неверный формат запроса", 400);
}
const { name, email, password, website, cfTurnstileResponse } = body;
// Honeypot — bots fill this field, humans don't see it
if (website) {
return jsonError("Registration failed", 400);
}
// Disposable email check
const domain = email?.split("@")[1]?.toLowerCase();
if (domain && disposableDomains.includes(domain)) {
return jsonError("Пожалуйста, используйте постоянный email-адрес", 400);
}
// Turnstile check
if (process.env.TURNSTILE_SECRET_KEY) {
if (!cfTurnstileResponse) {
return jsonError("Пожалуйста, подтвердите, что вы не робот", 400);
}
if (!(await verifyTurnstile(cfTurnstileResponse))) {
return jsonError("Проверка капчи не пройдена. Попробуйте ещё раз", 400);
}
}
const baseUrl =
process.env.BETTER_AUTH_URL ??
`https://${request.headers.get("host")}`;
return auth.handler(
new Request(`${baseUrl}/api/auth/sign-up/email`, {
method: "POST",
headers: new Headers({
"Content-Type": "application/json",
Origin: baseUrl,
}),
body: JSON.stringify({ name, email, password }),
})
);
}