diff --git a/README.md b/README.md index 38c3197..cd65005 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,25 @@ git push origin main 如果你使用 Cloudflare Workers 的 Git 集成(连接 GitHub/GitLab 仓库自动部署),需要先完成以下前置步骤: +### Cloudflare Turnstile(管理员登录验证码) + +项目现已支持在 **管理员登录页** 接入 Cloudflare Turnstile 小组件,用于拦截自动化爆破登录。 + +需要在 Cloudflare Dashboard 的 Turnstile 中创建站点,并配置以下环境变量: + +- `TURNSTILE_SITE_KEY`:前端小组件站点 Key +- `TURNSTILE_SECRET_KEY`:服务端校验 Secret Key + +使用命令给当前项目配置Turnstile +```bash +wrangler secret put TURNSTILE_SECRET_KEY +wrangler secret put TURNSTILE_SECRET_KEY +``` +说明: +- 两个变量都未配置时,Turnstile 默认关闭,不影响现有登录流程 +- 两个变量都正确配置后,后台登录页会自动显示 Turnstile 小组件,并在服务端强制校验 +- 如果只配置了其中一个变量,系统会自动视为未启用,避免出现半配置状态 + **0. 前置:在 Cloudflare Dashboard 创建 D1 数据库** 1. [创建数据库](#如何创建数据库) 名称填 `edgekey-db` diff --git a/pages/admin/login/+Page.vue b/pages/admin/login/+Page.vue index 3f12f95..52b759d 100644 --- a/pages/admin/login/+Page.vue +++ b/pages/admin/login/+Page.vue @@ -10,7 +10,8 @@ 使用管理员账号登录后台,进行商品、库存、订单和支付配置管理。

{{ errorMsg }}
-
+
{{ turnstileConfigError }}
+ - +
+
+

请先完成人机验证后再登录。

+
+ 登录后台 @@ -33,30 +44,140 @@ diff --git a/server/authjs-handler.ts b/server/authjs-handler.ts index ac011d4..d0f880f 100644 --- a/server/authjs-handler.ts +++ b/server/authjs-handler.ts @@ -4,9 +4,10 @@ import CredentialsProvider from "@auth/core/providers/credentials"; import type { Session } from "@auth/core/types"; import { enhance, type UniversalHandler, type UniversalMiddleware } from "@universal-middleware/core"; import { PrismaClient } from "../generated/prisma/client"; -import { internalServerError, rateLimitError } from "../lib/app-error"; +import { badRequestError, internalServerError, rateLimitError } from "../lib/app-error"; import { logger } from "../lib/logger"; import { verifyAdminPassword, hashAdminPassword } from "../modules/auth/crypto"; +import { getClientIpFromRequest, TURNSTILE_ACTION, verifyTurnstileToken } from "./turnstile"; const ADMIN_ROLE = "admin" as const; const loginAttemptStore = new Map(); @@ -41,20 +42,34 @@ function getLoginRateLimitConfig() { }; } -function getClientIp(request: Request) { - const forwarded = request.headers.get("x-forwarded-for"); - return request.headers.get("cf-connecting-ip") || forwarded?.split(",")[0]?.trim() || "unknown"; -} - function isCredentialsCallbackRequest(request: Request) { const url = new URL(request.url); return request.method === "POST" && url.pathname.endsWith("/api/auth/callback/credentials"); } +async function assertTurnstileValid(request: Request) { + const clonedRequest = request.clone(); + const contentType = clonedRequest.headers.get("content-type") || ""; + + if (!contentType.includes("application/x-www-form-urlencoded") && !contentType.includes("multipart/form-data")) { + throw badRequestError("登录请求格式不正确", "AUTH_INVALID_CONTENT_TYPE"); + } + + const formData = await clonedRequest.formData(); + const tokenValue = formData.get("cf-turnstile-response"); + const token = typeof tokenValue === "string" ? tokenValue : ""; + + await verifyTurnstileToken({ + token, + remoteIp: getClientIpFromRequest(request), + expectedAction: TURNSTILE_ACTION, + }); +} + function isRateLimited(request: Request) { const { maxAttempts, windowMs } = getLoginRateLimitConfig(); const now = Date.now(); - const key = getClientIp(request); + const key = getClientIpFromRequest(request); const current = loginAttemptStore.get(key); if (!current || current.expiresAt <= now) { @@ -217,11 +232,31 @@ export const authjsSessionMiddleware: UniversalMiddleware = enhance( **/ export const authjsHandler = enhance( async (request, context) => { - if (isCredentialsCallbackRequest(request) && isRateLimited(request)) { - const error = rateLimitError("Too Many Requests", "AUTH_RATE_LIMITED"); - return new Response(error.message, { - status: error.statusCode, - }); + if (isCredentialsCallbackRequest(request)) { + if (isRateLimited(request)) { + const error = rateLimitError("Too Many Requests", "AUTH_RATE_LIMITED"); + return new Response(error.message, { + status: error.statusCode, + }); + } + + try { + await assertTurnstileValid(request); + } catch (error) { + const appError = error instanceof Error ? error : new Error(String(error)); + logger.warn(appError, { + event: "auth.turnstile.validation_failed", + }); + + const url = new URL(request.url); + const callbackUrl = url.searchParams.get("callbackUrl") || "/admin"; + const redirectUrl = new URL("/admin/login", url.origin); + redirectUrl.searchParams.set("error", error instanceof Error && "code" in error && typeof (error as { code?: unknown }).code === "string" + ? String((error as { code?: string }).code) + : "turnstile_invalid"); + redirectUrl.searchParams.set("redirect", callbackUrl); + return Response.redirect(redirectUrl.toString(), 302); + } } const authContext = context as unknown as AuthContext; diff --git a/server/routes/index.ts b/server/routes/index.ts index fd61f4e..4107e5e 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -7,6 +7,7 @@ import { registerStripeRoutes } from "./payment-stripe"; import { registerRobotsRoutes } from "./robots"; import { registerSitemapRoutes } from "./sitemap"; import { registerMediaRoutes } from "./media"; +import { registerTurnstileRoutes } from "./turnstile"; // 集中注册所有 `/api/*` 路由,避免入口文件散落多个 register 调用。 export function registerApiRoutes(app: Hono) { @@ -18,5 +19,6 @@ export function registerApiRoutes(app: Hono) { registerRobotsRoutes(app); registerSitemapRoutes(app); registerMediaRoutes(app); + registerTurnstileRoutes(app); } diff --git a/server/routes/turnstile.ts b/server/routes/turnstile.ts new file mode 100644 index 0000000..990fa13 --- /dev/null +++ b/server/routes/turnstile.ts @@ -0,0 +1,14 @@ +import type { Hono } from "hono"; +import { getTurnstileConfig, TURNSTILE_ACTION } from "../turnstile"; + +export function registerTurnstileRoutes(app: Hono) { + app.get("/api/turnstile/config", (c) => { + const config = getTurnstileConfig(); + + return c.json({ + enabled: config.enabled, + siteKey: config.enabled ? config.siteKey : null, + action: TURNSTILE_ACTION, + }); + }); +} diff --git a/server/turnstile.ts b/server/turnstile.ts new file mode 100644 index 0000000..d25f963 --- /dev/null +++ b/server/turnstile.ts @@ -0,0 +1,134 @@ +import { badRequestError, externalServiceError } from "../lib/app-error"; +import { logger } from "../lib/logger"; + +export const TURNSTILE_ACTION = "admin_login" as const; +const TURNSTILE_VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"; + +export type TurnstileConfig = { + enabled: boolean; + siteKey: string | null; + secretKey: string | null; +}; + +type TurnstileVerifyResponse = { + success: boolean; + challenge_ts?: string; + hostname?: string; + "error-codes"?: string[]; + action?: string; + cdata?: string; + metadata?: { + ephemeral_id?: string; + }; +}; + +export function getTurnstileConfig(): TurnstileConfig { + const siteKey = process.env.TURNSTILE_SITE_KEY?.trim() || null; + const secretKey = process.env.TURNSTILE_SECRET_KEY?.trim() || null; + + if (!siteKey && !secretKey) { + return { + enabled: false, + siteKey: null, + secretKey: null, + }; + } + + if (!siteKey || !secretKey) { + logger.warn("turnstile.config.incomplete", { + hasSiteKey: Boolean(siteKey), + hasSecretKey: Boolean(secretKey), + }); + + return { + enabled: false, + siteKey, + secretKey, + }; + } + + return { + enabled: true, + siteKey, + secretKey, + }; +} + +export async function verifyTurnstileToken(input: { + token: string; + remoteIp?: string | null; + expectedAction?: string; +}): Promise { + const config = getTurnstileConfig(); + + if (!config.enabled || !config.secretKey) { + return; + } + + const token = input.token.trim(); + if (!token) { + throw badRequestError("请先完成人机验证", "turnstile_required"); + } + + const body = new URLSearchParams({ + secret: config.secretKey, + response: token, + }); + + if (input.remoteIp) { + body.set("remoteip", input.remoteIp); + } + + let result: TurnstileVerifyResponse; + + try { + const response = await fetch(TURNSTILE_VERIFY_URL, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + }, + body, + }); + + result = (await response.json()) as TurnstileVerifyResponse; + + if (!response.ok) { + logger.warn("turnstile.verify.http_error", { + status: response.status, + result, + }); + throw externalServiceError("人机验证服务暂时不可用,请稍后再试", "TURNSTILE_HTTP_ERROR"); + } + } catch (error) { + logger.error("turnstile.verify.request_failed", { error }); + throw externalServiceError("人机验证服务请求失败,请稍后再试", "TURNSTILE_REQUEST_FAILED", { + cause: error, + }); + } + + if (!result.success) { + logger.warn("turnstile.verify.failed", { + errorCodes: result["error-codes"], + action: result.action, + hostname: result.hostname, + }); + throw badRequestError("人机验证未通过,请重试", "turnstile_invalid", { + details: { + errorCodes: result["error-codes"], + }, + }); + } + + if (input.expectedAction && result.action && result.action !== input.expectedAction) { + logger.warn("turnstile.verify.action_mismatch", { + expectedAction: input.expectedAction, + actualAction: result.action, + }); + throw badRequestError("人机验证结果异常,请刷新页面后重试", "turnstile_invalid_action"); + } +} + +export function getClientIpFromRequest(request: Request) { + const forwarded = request.headers.get("x-forwarded-for"); + return request.headers.get("cf-connecting-ip") || forwarded?.split(",")[0]?.trim() || "unknown"; +} diff --git a/wrangler.jsonc b/wrangler.jsonc index 41bfebc..c81a543 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -13,7 +13,7 @@ "binding": "DB", "database_name": "edgekey-db", "migrations_dir": "prisma/migrations", - // "database_id": "24390dbc-b9c6-4ae6-8c7f-507fb2eb36f2", // 执行 wrangler d1 命令必须,但是与cf一键部署冲突所以注释 + "database_id": "24390dbc-b9c6-4ae6-8c7f-507fb2eb36f2", // 执行 wrangler d1 命令必须,但是与cf一键部署冲突所以注释 } ], "triggers": {