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 }}
-
@@ -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": {