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

@@ -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
View 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" }
}
}

View File

@@ -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>