feat(turnstile): integrate Cloudflare Turnstile for enhanced security in login and registration processes

This commit is contained in:
beilunyang
2025-10-22 23:31:48 +08:00
parent 1ffe920d47
commit e431c1fe5b
22 changed files with 480 additions and 56 deletions

View File

@@ -1,6 +1,6 @@
"use client"
import { useState } from "react"
import { useCallback, useState } from "react"
import { signIn } from "next-auth/react"
import { useTranslations } from "next-intl"
import { useToast } from "@/components/ui/use-toast"
@@ -21,6 +21,16 @@ import {
} from "@/components/ui/tabs"
import { Github, Loader2, KeyRound, User2 } from "lucide-react"
import { cn } from "@/lib/utils"
import { Turnstile } from "@/components/auth/turnstile"
interface TurnstileConfigProps {
enabled: boolean
siteKey: string
}
interface LoginFormProps {
turnstile?: TurnstileConfigProps
}
interface FormErrors {
username?: string
@@ -28,15 +38,50 @@ interface FormErrors {
confirmPassword?: string
}
export function LoginForm() {
export function LoginForm({ turnstile }: LoginFormProps) {
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [loading, setLoading] = useState(false)
const [errors, setErrors] = useState<FormErrors>({})
const [turnstileToken, setTurnstileToken] = useState("")
const [turnstileResetCounter, setTurnstileResetCounter] = useState(0)
const [activeTab, setActiveTab] = useState<"login" | "register">("login")
const { toast } = useToast()
const t = useTranslations("auth.loginForm")
const turnstileSiteKey = turnstile?.siteKey ?? ""
const turnstileEnabled = Boolean(turnstile?.enabled && turnstileSiteKey)
const resetTurnstile = useCallback(() => {
setTurnstileToken("")
setTurnstileResetCounter((prev) => prev + 1)
}, [])
const ensureTurnstileSolved = () => {
if (!turnstileEnabled) return true
if (turnstileToken) return true
toast({
title: t("toast.turnstileRequired"),
description: t("toast.turnstileRequiredDesc"),
variant: "destructive",
})
return false
}
const clearForm = () => {
setUsername("")
setPassword("")
setConfirmPassword("")
setErrors({})
}
const handleTabChange = (value: string) => {
setActiveTab(value as "login" | "register")
clearForm()
}
const validateLoginForm = () => {
const newErrors: FormErrors = {}
if (!username) newErrors.username = t("errors.usernameRequired")
@@ -61,22 +106,25 @@ export function LoginForm() {
const handleLogin = async () => {
if (!validateLoginForm()) return
if (!ensureTurnstileSolved()) return
setLoading(true)
try {
const result = await signIn("credentials", {
username,
password,
turnstileToken,
redirect: false,
})
if (result?.error) {
toast({
title: t("toast.loginFailed"),
description: t("toast.loginFailedDesc"),
description: result.error,
variant: "destructive",
})
setLoading(false)
resetTurnstile()
return
}
@@ -88,18 +136,20 @@ export function LoginForm() {
variant: "destructive",
})
setLoading(false)
resetTurnstile()
}
}
const handleRegister = async () => {
if (!validateRegisterForm()) return
if (!ensureTurnstileSolved()) return
setLoading(true)
try {
const response = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
body: JSON.stringify({ username, password, turnstileToken }),
})
const data = await response.json() as { error?: string }
@@ -111,6 +161,7 @@ export function LoginForm() {
variant: "destructive",
})
setLoading(false)
resetTurnstile()
return
}
@@ -118,16 +169,18 @@ export function LoginForm() {
const result = await signIn("credentials", {
username,
password,
turnstileToken,
redirect: false,
})
if (result?.error) {
toast({
title: t("toast.loginFailed"),
description: t("toast.autoLoginFailed"),
description: result.error || t("toast.autoLoginFailed"),
variant: "destructive",
})
setLoading(false)
resetTurnstile()
return
}
@@ -139,6 +192,7 @@ export function LoginForm() {
variant: "destructive",
})
setLoading(false)
resetTurnstile()
}
}
@@ -146,13 +200,6 @@ export function LoginForm() {
signIn("github", { callbackUrl: "/" })
}
const clearForm = () => {
setUsername("")
setPassword("")
setConfirmPassword("")
setErrors({})
}
return (
<Card className="w-[95%] max-w-lg border-2 border-primary/20">
<CardHeader className="space-y-2">
@@ -164,7 +211,7 @@ export function LoginForm() {
</CardDescription>
</CardHeader>
<CardContent className="px-6">
<Tabs defaultValue="login" className="w-full" onValueChange={clearForm}>
<Tabs value={activeTab} className="w-full" onValueChange={handleTabChange}>
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="login">{t("tabs.login")}</TabsTrigger>
<TabsTrigger value="register">{t("tabs.register")}</TabsTrigger>
@@ -340,7 +387,17 @@ export function LoginForm() {
</TabsContent>
</div>
</Tabs>
{turnstileEnabled && turnstileSiteKey && (
<div className={cn("space-y-2", activeTab === "login" ? "mt-4" : "")}>
<Turnstile
siteKey={turnstileSiteKey}
onVerify={setTurnstileToken}
onExpire={resetTurnstile}
resetSignal={turnstileResetCounter}
/>
</div>
)}
</CardContent>
</Card>
)
}
}

View File

@@ -0,0 +1,104 @@
"use client"
import { useEffect, useRef } from "react"
import { cn } from "@/lib/utils"
interface TurnstileProps {
siteKey: string
onVerify: (token: string) => void
onExpire?: () => void
resetSignal?: number
className?: string
}
export function Turnstile({
siteKey,
onVerify,
onExpire,
resetSignal,
className,
}: TurnstileProps) {
const containerRef = useRef<HTMLDivElement | null>(null)
const widgetIdRef = useRef<string>(null)
useEffect(() => {
if (!siteKey) return
const renderWidget = () => {
if (!containerRef.current || !window.turnstile) return
if (widgetIdRef.current) {
window.turnstile.reset(widgetIdRef.current)
return
}
widgetIdRef.current = window.turnstile.render(containerRef.current, {
sitekey: siteKey,
theme: "auto",
callback: (token: string) => onVerify(token),
"error-callback": () => onVerify(""),
"expired-callback": () => {
onVerify("")
onExpire?.()
},
})
}
const existingScript = document.querySelector<HTMLScriptElement>('script[data-turnstile="true"]')
if (window.turnstile) {
renderWidget()
} else if (existingScript) {
const handleExistingScriptLoad = () => renderWidget()
if (existingScript.dataset.loaded === "true") {
renderWidget()
} else {
existingScript.addEventListener("load", handleExistingScriptLoad)
}
return () => {
existingScript.removeEventListener("load", handleExistingScriptLoad)
}
} else {
const script = document.createElement("script")
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
script.async = true
script.defer = true
script.dataset.turnstile = "true"
const handleScriptLoad = () => {
script.dataset.loaded = "true"
renderWidget()
}
script.addEventListener("load", handleScriptLoad)
document.head.appendChild(script)
return () => {
script.removeEventListener("load", handleScriptLoad)
}
}
return () => {
if (widgetIdRef.current && window.turnstile) {
window.turnstile.remove(widgetIdRef.current)
widgetIdRef.current = null
}
}
}, [siteKey, onExpire, onVerify])
useEffect(() => {
if (resetSignal === undefined) return
if (widgetIdRef.current && window.turnstile) {
window.turnstile.reset(widgetIdRef.current)
}
onVerify("")
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resetSignal])
return (
<div
ref={containerRef}
className={cn("flex justify-center", className)}
/>
)
}

View File

@@ -7,6 +7,9 @@ import { useToast } from "@/components/ui/use-toast"
import { useState, useEffect } from "react"
import { Role, ROLES } from "@/lib/permissions"
import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"
import { Label } from "@/components/ui/label"
import { Eye, EyeOff } from "lucide-react"
import {
Select,
SelectContent,
@@ -23,6 +26,10 @@ export function WebsiteConfigPanel() {
const [emailDomains, setEmailDomains] = useState<string>("")
const [adminContact, setAdminContact] = useState<string>("")
const [maxEmails, setMaxEmails] = useState<string>(EMAIL_CONFIG.MAX_ACTIVE_EMAILS.toString())
const [turnstileEnabled, setTurnstileEnabled] = useState(false)
const [turnstileSiteKey, setTurnstileSiteKey] = useState("")
const [turnstileSecretKey, setTurnstileSecretKey] = useState("")
const [showSecretKey, setShowSecretKey] = useState(false)
const [loading, setLoading] = useState(false)
const { toast } = useToast()
@@ -38,12 +45,20 @@ export function WebsiteConfigPanel() {
defaultRole: Exclude<Role, typeof ROLES.EMPEROR>,
emailDomains: string,
adminContact: string,
maxEmails: string
maxEmails: string,
turnstile?: {
enabled: boolean,
siteKey: string,
secretKey?: string
}
}
setDefaultRole(data.defaultRole)
setEmailDomains(data.emailDomains)
setAdminContact(data.adminContact)
setMaxEmails(data.maxEmails || EMAIL_CONFIG.MAX_ACTIVE_EMAILS.toString())
setTurnstileEnabled(Boolean(data.turnstile?.enabled))
setTurnstileSiteKey(data.turnstile?.siteKey ?? "")
setTurnstileSecretKey(data.turnstile?.secretKey ?? "")
}
}
@@ -57,7 +72,12 @@ export function WebsiteConfigPanel() {
defaultRole,
emailDomains,
adminContact,
maxEmails: maxEmails || EMAIL_CONFIG.MAX_ACTIVE_EMAILS.toString()
maxEmails: maxEmails || EMAIL_CONFIG.MAX_ACTIVE_EMAILS.toString(),
turnstile: {
enabled: turnstileEnabled,
siteKey: turnstileSiteKey,
secretKey: turnstileSecretKey
}
}),
})
@@ -136,6 +156,63 @@ export function WebsiteConfigPanel() {
</div>
</div>
<div className="space-y-4 rounded-lg border border-dashed border-primary/40 p-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="turnstile-enabled" className="text-sm font-medium">
{t("turnstile.enable")}
</Label>
<p className="text-xs text-muted-foreground">
{t("turnstile.enableDescription")}
</p>
</div>
<Switch
id="turnstile-enabled"
checked={turnstileEnabled}
onCheckedChange={setTurnstileEnabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="turnstile-site-key" className="text-sm font-medium">
{t("turnstile.siteKey")}
</Label>
<Input
id="turnstile-site-key"
value={turnstileSiteKey}
onChange={(e) => setTurnstileSiteKey(e.target.value)}
placeholder={t("turnstile.siteKeyPlaceholder")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="turnstile-secret-key" className="text-sm font-medium">
{t("turnstile.secretKey")}
</Label>
<div className="relative">
<Input
id="turnstile-secret-key"
type={showSecretKey ? "text" : "password"}
value={turnstileSecretKey}
onChange={(e) => setTurnstileSecretKey(e.target.value)}
placeholder={t("turnstile.secretKeyPlaceholder")}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowSecretKey((prev) => !prev)}
>
{showSecretKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{t("turnstile.secretKeyDescription")}
</p>
</div>
</div>
<Button
onClick={handleSave}
disabled={loading}
@@ -146,4 +223,4 @@ export function WebsiteConfigPanel() {
</div>
</div>
)
}
}