From e860425e081de3dfce42d75762bab1c097733647 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Thu, 10 Sep 2026 21:39:07 +0700 Subject: [PATCH] feat(auth) : protect admin login with turnstile --- .env.example | 4 ++ app/admin/login/page.tsx | 7 ++- components/admin/login-card.tsx | 89 +++++++++++++++++++++++++++++-- lib/auth/server.ts | 12 ++++- tests/security-boundaries.test.ts | 21 ++++++++ 5 files changed, 127 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 1734305..efef8c1 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,10 @@ BETTER_AUTH_SECRET=replace-with-at-least-32-random-bytes # Separate trusted browser origins with commas for local development or proxies. BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000 +# Cloudflare Turnstile administrator login protection +TURNSTILE_SITE_KEY=replace-with-turnstile-site-key +TURNSTILE_SECRET_KEY=replace-with-turnstile-secret-key + # Redis remote cache, event transport, and outbox worker REDIS_URL=rediss://default:replace-me@redis.example.internal:6379 REDIS_CACHE_PREFIX=buzz:next-cache diff --git a/app/admin/login/page.tsx b/app/admin/login/page.tsx index 05bc6ac..943b8be 100644 --- a/app/admin/login/page.tsx +++ b/app/admin/login/page.tsx @@ -9,11 +9,16 @@ export default async function AdminLoginPage() { await connection(); if (await getAdminSession()) redirect("/admin"); + const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY; + if (!turnstileSiteKey) { + throw new Error("TURNSTILE_SITE_KEY is required for administrator login."); + } + return (
- +
); diff --git a/components/admin/login-card.tsx b/components/admin/login-card.tsx index 2bdb6f6..b26f7d0 100644 --- a/components/admin/login-card.tsx +++ b/components/admin/login-card.tsx @@ -3,7 +3,8 @@ import { CircleAlertIcon, LogInIcon } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { type FormEvent, useState } from "react"; +import Script from "next/script"; +import { type FormEvent, useCallback, useRef, useState } from "react"; import { Alert, @@ -24,13 +25,73 @@ import { Input } from "@/components/ui/input"; import { Spinner } from "@/components/ui/spinner"; import { authClient } from "@/lib/auth/client"; -export function LoginCard() { +interface TurnstileApi { + render( + container: HTMLElement, + options: { + sitekey: string; + callback: (token: string) => void; + "expired-callback": () => void; + "error-callback": () => void; + }, + ): string; + reset(widgetId: string): void; +} + +declare global { + interface Window { + turnstile?: TurnstileApi; + } +} + +interface LoginCardProps { + turnstileSiteKey: string; +} + +export function LoginCard({ turnstileSiteKey }: LoginCardProps) { const router = useRouter(); + const turnstileContainerRef = useRef(null); + const turnstileWidgetIdRef = useRef(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [turnstileToken, setTurnstileToken] = useState(null); + + const renderTurnstile = useCallback(() => { + if ( + !window.turnstile || + !turnstileContainerRef.current || + turnstileWidgetIdRef.current + ) { + return; + } + + turnstileWidgetIdRef.current = window.turnstile.render( + turnstileContainerRef.current, + { + sitekey: turnstileSiteKey, + callback: (token) => { + setTurnstileToken(token); + setError(null); + }, + "expired-callback": () => setTurnstileToken(null), + "error-callback": () => { + setTurnstileToken(null); + setError("ยืนยันตัวตนไม่สำเร็จ โปรดลองอีกครั้ง"); + }, + }, + ); + }, [turnstileSiteKey]); + + function resetTurnstile() { + setTurnstileToken(null); + if (window.turnstile && turnstileWidgetIdRef.current) { + window.turnstile.reset(turnstileWidgetIdRef.current); + } + } async function signIn(event: FormEvent) { event.preventDefault(); + if (!turnstileToken) return; setLoading(true); setError(null); @@ -43,6 +104,9 @@ export function LoginCard() { email, password, rememberMe: true, + fetchOptions: { + headers: { "x-captcha-response": turnstileToken }, + }, }); if (result.error) { setError( @@ -57,6 +121,7 @@ export function LoginCard() { } catch { setError("เชื่อมต่อระบบเข้าสู่ระบบไม่ได้ โปรดลองอีกครั้ง"); } finally { + resetTurnstile(); setLoading(false); } } @@ -96,6 +161,19 @@ export function LoginCard() { required /> + +
+