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")} +

+
+
+