feat: 登陆开启cloudflare盾

This commit is contained in:
ggyy
2026-05-13 17:01:32 +08:00
parent 734b722f47
commit d3589448b0
7 changed files with 349 additions and 24 deletions

View File

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

View File

@@ -10,7 +10,8 @@
使用管理员账号登录后台进行商品库存订单和支付配置管理
</p>
<div v-if="errorMsg" class="alert alert-error text-sm">{{ errorMsg }}</div>
<form class="space-y-4" method="post" action="/api/auth/callback/credentials?callbackUrl=/admin">
<div v-else-if="turnstileConfigError" class="alert alert-warning text-sm">{{ turnstileConfigError }}</div>
<form class="space-y-4" method="post" :action="formAction">
<input type="hidden" name="csrfToken" :value="csrfToken" />
<label class="flex flex-col gap-1.5">
<span class="label-text font-medium">用户名</span>
@@ -20,7 +21,17 @@
<span class="label-text font-medium">密码</span>
<input name="password" type="password" class="input input-bordered w-full" placeholder="请输入密码" required />
</label>
<AppButton type="submit" variant="primary" :loading="loading" :disabled="!csrfToken" block>
<div v-if="turnstileEnabled" class="space-y-2">
<div
ref="turnstileContainerRef"
class="cf-turnstile"
:data-sitekey="turnstileSiteKey"
:data-action="turnstileAction"
data-theme="auto"
></div>
<p class="text-xs text-base-content/60">请先完成人机验证后再登录</p>
</div>
<AppButton type="submit" variant="primary" :loading="loading" :disabled="submitDisabled" block>
登录后台
</AppButton>
</form>
@@ -33,30 +44,140 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import AppButton from "../../../components/AppButton.vue";
declare global {
interface Window {
turnstile?: {
render: (container: string | HTMLElement, options: Record<string, unknown>) => string;
remove: (widgetId: string) => void;
reset: (widgetId?: string) => void;
};
}
}
type TurnstileConfigResponse = {
enabled?: boolean;
siteKey?: string | null;
action?: string | null;
};
const csrfToken = ref("");
const loading = ref(true);
const errorMsg = ref("");
const turnstileConfigError = ref("");
const turnstileEnabled = ref(false);
const turnstileSiteKey = ref("");
const turnstileAction = ref("admin_login");
const turnstileToken = ref("");
const turnstileWidgetId = ref<string | null>(null);
const turnstileContainerRef = ref<HTMLElement | null>(null);
const redirectPath = ref("/admin");
const ERROR_MAP: Record<string, string> = {
CredentialsSignin: "用户名或密码错误",
password_upgrade_failed: "登录成功但密码升级失败,请重置密码后重试",
turnstile_required: "请先完成人机验证",
turnstile_invalid: "人机验证未通过,请重试",
turnstile_invalid_action: "人机验证结果异常,请刷新页面后重试",
AUTH_RATE_LIMITED: "登录过于频繁,请稍后再试",
};
onMounted(async () => {
const params = new URLSearchParams(location.search);
const formAction = computed(() => `/api/auth/callback/credentials?callbackUrl=${encodeURIComponent(redirectPath.value)}`);
const submitDisabled = computed(() => !csrfToken.value || (turnstileEnabled.value && !turnstileToken.value));
function ensureTurnstileScript() {
return new Promise<void>((resolve, reject) => {
if (window.turnstile) {
resolve();
return;
}
const existing = document.querySelector<HTMLScriptElement>('script[data-turnstile-script="true"]');
if (existing) {
existing.addEventListener("load", () => resolve(), { once: true });
existing.addEventListener("error", () => reject(new Error("TURNSTILE_SCRIPT_LOAD_FAILED")), { once: true });
return;
}
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.turnstileScript = "true";
script.addEventListener("load", () => resolve(), { once: true });
script.addEventListener("error", () => reject(new Error("TURNSTILE_SCRIPT_LOAD_FAILED")), { once: true });
document.head.appendChild(script);
});
}
function renderTurnstileWidget() {
if (!turnstileEnabled.value || !turnstileSiteKey.value || !turnstileContainerRef.value || !window.turnstile) {
return;
}
if (turnstileWidgetId.value) {
window.turnstile.remove(turnstileWidgetId.value);
turnstileWidgetId.value = null;
}
turnstileWidgetId.value = window.turnstile.render(turnstileContainerRef.value, {
sitekey: turnstileSiteKey.value,
action: turnstileAction.value,
callback(token: unknown) {
turnstileToken.value = typeof token === "string" ? token : "";
},
"expired-callback"() {
turnstileToken.value = "";
},
"error-callback"() {
turnstileToken.value = "";
turnstileConfigError.value = "人机验证加载失败,请刷新页面后重试";
},
});
}
async function loadPageData() {
const params = new URLSearchParams(window.location.search);
redirectPath.value = params.get("redirect") || "/admin";
const code = params.get("code") ?? params.get("error");
if (code) errorMsg.value = ERROR_MAP[code] ?? "登录失败,请重试";
if (code) {
errorMsg.value = ERROR_MAP[code] ?? "登录失败,请重试";
}
const [csrfResponse, turnstileResponse] = await Promise.all([
fetch("/api/auth/csrf", { credentials: "same-origin" }),
fetch("/api/turnstile/config", { credentials: "same-origin" }),
]);
const csrfData = (await csrfResponse.json()) as { csrfToken?: string };
csrfToken.value = csrfData.csrfToken ?? "";
const turnstileData = (await turnstileResponse.json()) as TurnstileConfigResponse;
turnstileEnabled.value = Boolean(turnstileData.enabled && turnstileData.siteKey);
turnstileSiteKey.value = turnstileData.siteKey ?? "";
turnstileAction.value = turnstileData.action || "admin_login";
if (turnstileEnabled.value) {
await ensureTurnstileScript();
renderTurnstileWidget();
}
}
onMounted(async () => {
try {
const response = await fetch("/api/auth/csrf", {
credentials: "same-origin",
});
const data = (await response.json()) as { csrfToken?: string };
csrfToken.value = data.csrfToken ?? "";
await loadPageData();
} catch {
turnstileConfigError.value = "登录页初始化失败,请刷新页面后重试";
} finally {
loading.value = false;
}
});
onBeforeUnmount(() => {
if (turnstileWidgetId.value && window.turnstile) {
window.turnstile.remove(turnstileWidgetId.value);
}
});
</script>

View File

@@ -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<string, { count: number; expiresAt: number }>();
@@ -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;

View File

@@ -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);
}

View File

@@ -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,
});
});
}

134
server/turnstile.ts Normal file
View File

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

View File

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