diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx
index f496d35..6dc719e 100644
--- a/app/[locale]/login/page.tsx
+++ b/app/[locale]/login/page.tsx
@@ -2,6 +2,7 @@ import { LoginForm } from "@/components/auth/login-form"
import { auth } from "@/lib/auth"
import { redirect } from "next/navigation"
import type { Locale } from "@/i18n/config"
+import { getTurnstileConfig } from "@/lib/turnstile"
export const runtime = "edge"
@@ -18,10 +19,11 @@ export default async function LoginPage({
redirect(`/${locale}`)
}
+ const turnstile = await getTurnstileConfig()
+
return (
-
+
)
}
-
diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts
index 18c2513..c254eeb 100644
--- a/app/api/auth/register/route.ts
+++ b/app/api/auth/register/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server"
import { register } from "@/lib/auth"
import { authSchema, AuthSchema } from "@/lib/validation"
+import { verifyTurnstileToken } from "@/lib/turnstile"
export const runtime = "edge"
@@ -17,7 +18,16 @@ export async function POST(request: Request) {
)
}
- const { username, password } = json
+ const { username, password, turnstileToken } = json
+
+ const verification = await verifyTurnstileToken(turnstileToken)
+ if (!verification.success) {
+ const message = verification.reason === "missing-token"
+ ? "请先完成安全验证"
+ : "安全验证未通过"
+ return NextResponse.json({ error: message }, { status: 400 })
+ }
+
const user = await register(username, password)
return NextResponse.json({ user })
@@ -27,4 +37,4 @@ export async function POST(request: Request) {
{ status: 500 }
)
}
-}
\ No newline at end of file
+}
diff --git a/app/api/config/route.ts b/app/api/config/route.ts
index 76499d8..b45124c 100644
--- a/app/api/config/route.ts
+++ b/app/api/config/route.ts
@@ -7,18 +7,36 @@ export const runtime = "edge"
export async function GET() {
const env = getRequestContext().env
- const [defaultRole, emailDomains, adminContact, maxEmails] = await Promise.all([
+ const canManageConfig = await checkPermission(PERMISSIONS.MANAGE_CONFIG)
+
+ const [
+ defaultRole,
+ emailDomains,
+ adminContact,
+ maxEmails,
+ turnstileEnabled,
+ turnstileSiteKey,
+ turnstileSecretKey
+ ] = await Promise.all([
env.SITE_CONFIG.get("DEFAULT_ROLE"),
env.SITE_CONFIG.get("EMAIL_DOMAINS"),
env.SITE_CONFIG.get("ADMIN_CONTACT"),
- env.SITE_CONFIG.get("MAX_EMAILS")
+ env.SITE_CONFIG.get("MAX_EMAILS"),
+ env.SITE_CONFIG.get("TURNSTILE_ENABLED"),
+ env.SITE_CONFIG.get("TURNSTILE_SITE_KEY"),
+ env.SITE_CONFIG.get("TURNSTILE_SECRET_KEY")
])
return Response.json({
defaultRole: defaultRole || ROLES.CIVILIAN,
emailDomains: emailDomains || "moemail.app",
adminContact: adminContact || "",
- maxEmails: maxEmails || EMAIL_CONFIG.MAX_ACTIVE_EMAILS.toString()
+ maxEmails: maxEmails || EMAIL_CONFIG.MAX_ACTIVE_EMAILS.toString(),
+ turnstile: {
+ enabled: turnstileEnabled === "true",
+ siteKey: turnstileSiteKey || "",
+ ...(canManageConfig ? { secretKey: turnstileSecretKey || "" } : {})
+ }
})
}
@@ -31,24 +49,48 @@ export async function POST(request: Request) {
}, { status: 403 })
}
- const { defaultRole, emailDomains, adminContact, maxEmails } = await request.json() as {
+ const {
+ defaultRole,
+ emailDomains,
+ adminContact,
+ maxEmails,
+ turnstile
+ } = await request.json() as {
defaultRole: Exclude,
emailDomains: string,
adminContact: string,
- maxEmails: string
+ maxEmails: string,
+ turnstile?: {
+ enabled: boolean,
+ siteKey: string,
+ secretKey: string
+ }
}
if (![ROLES.DUKE, ROLES.KNIGHT, ROLES.CIVILIAN].includes(defaultRole)) {
return Response.json({ error: "无效的角色" }, { status: 400 })
}
+ const turnstileConfig = turnstile ?? {
+ enabled: false,
+ siteKey: "",
+ secretKey: ""
+ }
+
+ if (turnstileConfig.enabled && (!turnstileConfig.siteKey || !turnstileConfig.secretKey)) {
+ return Response.json({ error: "Turnstile 启用时需要提供 Site Key 和 Secret Key" }, { status: 400 })
+ }
+
const env = getRequestContext().env
await Promise.all([
env.SITE_CONFIG.put("DEFAULT_ROLE", defaultRole),
env.SITE_CONFIG.put("EMAIL_DOMAINS", emailDomains),
env.SITE_CONFIG.put("ADMIN_CONTACT", adminContact),
- env.SITE_CONFIG.put("MAX_EMAILS", maxEmails)
+ env.SITE_CONFIG.put("MAX_EMAILS", maxEmails),
+ env.SITE_CONFIG.put("TURNSTILE_ENABLED", turnstileConfig.enabled.toString()),
+ env.SITE_CONFIG.put("TURNSTILE_SITE_KEY", turnstileConfig.siteKey),
+ env.SITE_CONFIG.put("TURNSTILE_SECRET_KEY", turnstileConfig.secretKey)
])
return Response.json({ success: true })
-}
\ No newline at end of file
+}
diff --git a/app/components/auth/login-form.tsx b/app/components/auth/login-form.tsx
index 764a3ca..e2374b9 100644
--- a/app/components/auth/login-form.tsx
+++ b/app/components/auth/login-form.tsx
@@ -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({})
+ 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 (
@@ -164,7 +211,7 @@ export function LoginForm() {
-
+
{t("tabs.login")}
{t("tabs.register")}
@@ -340,7 +387,17 @@ export function LoginForm() {
+ {turnstileEnabled && turnstileSiteKey && (
+
+
+
+ )}
)
-}
\ No newline at end of file
+}
diff --git a/app/components/auth/turnstile.tsx b/app/components/auth/turnstile.tsx
new file mode 100644
index 0000000..c71e72f
--- /dev/null
+++ b/app/components/auth/turnstile.tsx
@@ -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(null)
+ const widgetIdRef = useRef(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('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 (
+
+ )
+}
diff --git a/app/components/profile/website-config-panel.tsx b/app/components/profile/website-config-panel.tsx
index 01f8d55..4abd483 100644
--- a/app/components/profile/website-config-panel.tsx
+++ b/app/components/profile/website-config-panel.tsx
@@ -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("")
const [adminContact, setAdminContact] = useState("")
const [maxEmails, setMaxEmails] = useState(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,
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() {
+
+
+
+
+
+ {t("turnstile.enableDescription")}
+
+
+
+
+
+
+
+ setTurnstileSiteKey(e.target.value)}
+ placeholder={t("turnstile.siteKeyPlaceholder")}
+ />
+
+
+
+
+
+ setTurnstileSecretKey(e.target.value)}
+ placeholder={t("turnstile.secretKeyPlaceholder")}
+ />
+
+
+
+ {t("turnstile.secretKeyDescription")}
+
+
+
+