mirror of
https://github.com/34892002/edgeKey.git
synced 2026-09-03 06:47:41 +08:00
feat: 登陆开启cloudflare盾
This commit is contained in:
19
README.md
19
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`
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
14
server/routes/turnstile.ts
Normal file
14
server/routes/turnstile.ts
Normal 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
134
server/turnstile.ts
Normal 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";
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user