mirror of
https://github.com/beilunyang/moemail.git
synced 2025-12-24 11:30:51 +08:00
feat(turnstile): integrate Cloudflare Turnstile for enhanced security in login and registration processes
This commit is contained in:
@@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800">
|
||||
<LoginForm />
|
||||
<LoginForm turnstile={{ enabled: turnstile.enabled, siteKey: turnstile.siteKey }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Role, typeof ROLES.EMPEROR>,
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,11 @@
|
||||
"loginFailedDesc": "Incorrect username or password",
|
||||
"registerFailed": "Registration Failed",
|
||||
"registerFailedDesc": "Please try again later",
|
||||
"autoLoginFailed": "Auto-login failed, please login manually"
|
||||
"autoLoginFailed": "Auto-login failed, please login manually",
|
||||
"turnstileRequired": "Please complete the verification",
|
||||
"turnstileRequiredDesc": "Solve the Turnstile challenge below before continuing",
|
||||
"registerSuccess": "Registration Successful",
|
||||
"registerSuccessDesc": "Switch to the login tab and complete verification to sign in"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
"title": "Insufficient Permission",
|
||||
"description": "You don't have permission to access this page. Please contact the website administrator.",
|
||||
"adminContact": "Admin Contact",
|
||||
"backToHome": "Back to Home"
|
||||
"backToHome": "Back to Home",
|
||||
"needPermission": "需要公爵或更高权限才能管理 API Key",
|
||||
"contactAdmin": "请联系网站管理员升级您的角色"
|
||||
},
|
||||
"layout": {
|
||||
"myEmails": "My Emails",
|
||||
@@ -165,4 +167,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,15 @@
|
||||
"adminContact": "Admin Contact",
|
||||
"adminContactPlaceholder": "Email or other contact method",
|
||||
"maxEmails": "Max Emails per User",
|
||||
"turnstile": {
|
||||
"enable": "Enable Cloudflare Turnstile",
|
||||
"enableDescription": "When enabled, username/password login and registration require Turnstile verification",
|
||||
"siteKey": "Site Key",
|
||||
"siteKeyPlaceholder": "Enter Turnstile Site Key",
|
||||
"secretKey": "Secret Key",
|
||||
"secretKeyPlaceholder": "Enter Turnstile Secret Key",
|
||||
"secretKeyDescription": "Set up a Turnstile application in Cloudflare and provide the required keys before enabling"
|
||||
},
|
||||
"save": "Save Configuration",
|
||||
"saving": "Saving...",
|
||||
"saveSuccess": "Configuration saved successfully",
|
||||
@@ -128,4 +137,3 @@
|
||||
"updateFailed": "Failed to update user role"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,8 +36,11 @@
|
||||
"loginFailedDesc": "ユーザー名またはパスワードが正しくありません",
|
||||
"registerFailed": "登録に失敗しました",
|
||||
"registerFailedDesc": "しばらくしてからもう一度お試しください",
|
||||
"autoLoginFailed": "自動ログインに失敗しました。手動でログインしてください"
|
||||
"autoLoginFailed": "自動ログインに失敗しました。手動でログインしてください",
|
||||
"turnstileRequired": "まず認証を完了してください",
|
||||
"turnstileRequiredDesc": "続行する前に下の Turnstile 認証を完了してください",
|
||||
"registerSuccess": "登録が完了しました",
|
||||
"registerSuccessDesc": "ログインタブに切り替え、認証を完了してログインしてください"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
"title": "権限がありません",
|
||||
"description": "このページにアクセスする権限がありません。サイト管理者に連絡してください",
|
||||
"adminContact": "管理者の連絡先",
|
||||
"backToHome": "ホームに戻る"
|
||||
"backToHome": "ホームに戻る",
|
||||
"needPermission": "需要公爵或更高权限才能管理 API Key",
|
||||
"contactAdmin": "请联系网站管理员升级您的角色"
|
||||
},
|
||||
"layout": {
|
||||
"myEmails": "マイメールボックス",
|
||||
@@ -165,4 +167,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,15 @@
|
||||
"adminContact": "管理者連絡先",
|
||||
"adminContactPlaceholder": "メールまたはその他の連絡先",
|
||||
"maxEmails": "ユーザーあたりの最大メールボックス数",
|
||||
"turnstile": {
|
||||
"enable": "Cloudflare Turnstile を有効化",
|
||||
"enableDescription": "有効にすると、ユーザー名とパスワードでのログイン・登録に Turnstile 認証が必要になります",
|
||||
"siteKey": "Site Key",
|
||||
"siteKeyPlaceholder": "Turnstile Site Key を入力",
|
||||
"secretKey": "Secret Key",
|
||||
"secretKeyPlaceholder": "Turnstile Secret Key を入力",
|
||||
"secretKeyDescription": "Cloudflare で Turnstile を作成し、必須のキーを入力してから有効化してください"
|
||||
},
|
||||
"save": "設定を保存",
|
||||
"saving": "保存中...",
|
||||
"saveSuccess": "設定を保存しました",
|
||||
@@ -128,4 +137,3 @@
|
||||
"updateFailed": "ユーザーロールの更新に失敗しました"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,8 +36,11 @@
|
||||
"loginFailedDesc": "用户名或密码错误",
|
||||
"registerFailed": "注册失败",
|
||||
"registerFailedDesc": "请稍后重试",
|
||||
"autoLoginFailed": "自动登录失败,请手动登录"
|
||||
"autoLoginFailed": "自动登录失败,请手动登录",
|
||||
"turnstileRequired": "请先完成人机验证",
|
||||
"turnstileRequiredDesc": "请完成下方的 Turnstile 验证后再尝试",
|
||||
"registerSuccess": "注册成功",
|
||||
"registerSuccessDesc": "请切换到登录选项卡,通过验证后登录账号"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
"title": "权限不足",
|
||||
"description": "你没有权限访问此页面,请联系网站管理员",
|
||||
"adminContact": "管理员联系方式",
|
||||
"backToHome": "返回首页"
|
||||
"backToHome": "返回首页",
|
||||
"needPermission": "需要公爵或更高权限才能管理 API Key",
|
||||
"contactAdmin": "请联系网站管理员升级您的角色"
|
||||
},
|
||||
"layout": {
|
||||
"myEmails": "我的邮箱",
|
||||
@@ -165,4 +167,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,15 @@
|
||||
"adminContact": "管理员联系方式",
|
||||
"adminContactPlaceholder": "邮箱或其他联系方式",
|
||||
"maxEmails": "每个用户最大邮箱数",
|
||||
"turnstile": {
|
||||
"enable": "启用 Cloudflare Turnstile",
|
||||
"enableDescription": "开启后,账号登录与注册将需要通过 Turnstile 验证",
|
||||
"siteKey": "Site Key",
|
||||
"siteKeyPlaceholder": "输入 Turnstile Site Key",
|
||||
"secretKey": "Secret Key",
|
||||
"secretKeyPlaceholder": "输入 Turnstile Secret Key",
|
||||
"secretKeyDescription": "开启前请先在 Cloudflare 创建 Turnstile,并填写上述必填参数"
|
||||
},
|
||||
"save": "保存配置",
|
||||
"saving": "保存中...",
|
||||
"saveSuccess": "配置保存成功",
|
||||
@@ -128,4 +137,3 @@
|
||||
"updateFailed": "更新用户角色失败"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,8 +36,11 @@
|
||||
"loginFailedDesc": "使用者名稱或密碼錯誤",
|
||||
"registerFailed": "註冊失敗",
|
||||
"registerFailedDesc": "請稍後再試",
|
||||
"autoLoginFailed": "自動登入失敗,請手動登入"
|
||||
"autoLoginFailed": "自動登入失敗,請手動登入",
|
||||
"turnstileRequired": "請先完成驗證",
|
||||
"turnstileRequiredDesc": "請先完成下方的 Turnstile 驗證再繼續",
|
||||
"registerSuccess": "註冊成功",
|
||||
"registerSuccessDesc": "請切換至登入分頁,完成驗證後登入帳號"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
"title": "權限不足",
|
||||
"description": "你沒有權限訪問此頁面,請聯絡網站管理員",
|
||||
"adminContact": "管理員聯絡方式",
|
||||
"backToHome": "返回首頁"
|
||||
"backToHome": "返回首頁",
|
||||
"needPermission": "需要公爵或更高權限才能管理 API Key",
|
||||
"contactAdmin": "請聯絡網站管理員升級你的角色"
|
||||
},
|
||||
"layout": {
|
||||
"myEmails": "我的郵箱",
|
||||
@@ -165,4 +167,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,15 @@
|
||||
"adminContact": "管理員聯絡方式",
|
||||
"adminContactPlaceholder": "郵箱或其他聯絡方式",
|
||||
"maxEmails": "每個使用者最大郵箱數",
|
||||
"turnstile": {
|
||||
"enable": "啟用 Cloudflare Turnstile",
|
||||
"enableDescription": "啟用後,使用者名稱與密碼的登入/註冊需要通過 Turnstile 驗證",
|
||||
"siteKey": "Site Key",
|
||||
"siteKeyPlaceholder": "輸入 Turnstile Site Key",
|
||||
"secretKey": "Secret Key",
|
||||
"secretKeyPlaceholder": "輸入 Turnstile Secret Key",
|
||||
"secretKeyDescription": "請先在 Cloudflare 建立 Turnstile 並填寫上述必填參數後再啟用"
|
||||
},
|
||||
"save": "儲存設定",
|
||||
"saving": "儲存中...",
|
||||
"saveSuccess": "設定儲存成功",
|
||||
@@ -128,4 +137,3 @@
|
||||
"updateFailed": "更新使用者角色失敗"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,10 @@ import { getRequestContext } from "@cloudflare/next-on-pages"
|
||||
import { Permission, hasPermission, ROLES, Role } from "./permissions"
|
||||
import CredentialsProvider from "next-auth/providers/credentials"
|
||||
import { hashPassword, comparePassword } from "@/lib/utils"
|
||||
import { authSchema } from "@/lib/validation"
|
||||
import { authSchema, AuthSchema } from "@/lib/validation"
|
||||
import { generateAvatarUrl } from "./avatar"
|
||||
import { getUserId } from "./apiKey"
|
||||
import { verifyTurnstileToken } from "./turnstile"
|
||||
|
||||
const ROLE_DESCRIPTIONS: Record<Role, string> = {
|
||||
[ROLES.EMPEROR]: "皇帝(网站所有者)",
|
||||
@@ -113,26 +114,35 @@ export const {
|
||||
throw new Error("请输入用户名和密码")
|
||||
}
|
||||
|
||||
const { username, password } = credentials
|
||||
const { username, password, turnstileToken } = credentials as Record<string, string | undefined>
|
||||
|
||||
let parsedCredentials: AuthSchema
|
||||
try {
|
||||
authSchema.parse({ username, password })
|
||||
parsedCredentials = authSchema.parse({ username, password, turnstileToken })
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (error) {
|
||||
throw new Error("输入格式不正确")
|
||||
}
|
||||
|
||||
const verification = await verifyTurnstileToken(parsedCredentials.turnstileToken)
|
||||
if (!verification.success) {
|
||||
if (verification.reason === "missing-token") {
|
||||
throw new Error("请先完成安全验证")
|
||||
}
|
||||
throw new Error("安全验证未通过")
|
||||
}
|
||||
|
||||
const db = createDb()
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.username, username as string),
|
||||
where: eq(users.username, parsedCredentials.username),
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
throw new Error("用户名或密码错误")
|
||||
}
|
||||
|
||||
const isValid = await comparePassword(password as string, user.password as string)
|
||||
const isValid = await comparePassword(parsedCredentials.password, user.password as string)
|
||||
if (!isValid) {
|
||||
throw new Error("用户名或密码错误")
|
||||
}
|
||||
|
||||
65
app/lib/turnstile.ts
Normal file
65
app/lib/turnstile.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { getRequestContext } from "@cloudflare/next-on-pages"
|
||||
|
||||
interface TurnstileConfig {
|
||||
enabled: boolean
|
||||
siteKey: string
|
||||
secretKey: string
|
||||
}
|
||||
|
||||
export async function getTurnstileConfig(): Promise<TurnstileConfig> {
|
||||
const env = getRequestContext().env
|
||||
const [enabled, siteKey, secretKey] = await Promise.all([
|
||||
env.SITE_CONFIG.get("TURNSTILE_ENABLED"),
|
||||
env.SITE_CONFIG.get("TURNSTILE_SITE_KEY"),
|
||||
env.SITE_CONFIG.get("TURNSTILE_SECRET_KEY"),
|
||||
])
|
||||
|
||||
return {
|
||||
enabled: enabled === "true",
|
||||
siteKey: siteKey || "",
|
||||
secretKey: secretKey || "",
|
||||
}
|
||||
}
|
||||
|
||||
export interface TurnstileVerificationResult {
|
||||
success: boolean
|
||||
reason?: "missing-token" | "verification-failed"
|
||||
}
|
||||
|
||||
export async function verifyTurnstileToken(token?: string | null): Promise<TurnstileVerificationResult> {
|
||||
const config = await getTurnstileConfig()
|
||||
|
||||
if (!config.enabled || !config.siteKey || !config.secretKey) {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
const trimmedToken = token?.trim()
|
||||
if (!trimmedToken) {
|
||||
return { success: false, reason: "missing-token" }
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: `secret=${encodeURIComponent(config.secretKey)}&response=${encodeURIComponent(trimmedToken)}`,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, reason: "verification-failed" }
|
||||
}
|
||||
|
||||
const data = await response.json() as { success: boolean }
|
||||
|
||||
if (!data.success) {
|
||||
return { success: false, reason: "verification-failed" }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error("Turnstile verification error:", error)
|
||||
return { success: false, reason: "verification-failed" }
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,8 @@ export const authSchema = z.object({
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, "用户名只能包含字母、数字、下划线和横杠")
|
||||
.refine(val => !val.includes('@'), "用户名不能是邮箱格式"),
|
||||
password: z.string()
|
||||
.min(8, "密码长度必须大于等于8位")
|
||||
.min(8, "密码长度必须大于等于8位"),
|
||||
turnstileToken: z.string().optional()
|
||||
})
|
||||
|
||||
export type AuthSchema = z.infer<typeof authSchema>
|
||||
export type AuthSchema = z.infer<typeof authSchema>
|
||||
|
||||
10
types.d.ts
vendored
10
types.d.ts
vendored
@@ -7,6 +7,14 @@ declare global {
|
||||
SITE_CONFIG: KVNamespace;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
turnstile?: {
|
||||
render: (element: HTMLElement | string, options: Record<string, unknown>) => string
|
||||
reset: (widgetId?: string) => void
|
||||
remove: (widgetId: string) => void
|
||||
}
|
||||
}
|
||||
|
||||
type Env = CloudflareEnv
|
||||
}
|
||||
|
||||
@@ -21,4 +29,4 @@ declare module "next-auth" {
|
||||
}
|
||||
}
|
||||
|
||||
export type { Env }
|
||||
export type { Env }
|
||||
|
||||
Reference in New Issue
Block a user