mirror of
https://github.com/beilunyang/moemail.git
synced 2026-06-02 14:10:32 +08:00
feat(turnstile): integrate Cloudflare Turnstile for enhanced security in login and registration processes
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
104
app/components/auth/turnstile.tsx
Normal file
104
app/components/auth/turnstile.tsx
Normal 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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user