feat: replace legacy captcha with Cloudflare Turnstile

This commit is contained in:
sagit
2026-02-08 04:51:41 +00:00
parent a74eb9f431
commit 2710cd1674
9 changed files with 181 additions and 416 deletions
+22 -40
View File
@@ -70,39 +70,22 @@ const CONFIG_ITEMS: ConfigItem[] = [
type: "switch",
},
{
key: "captcha_type",
label: "验证码类型",
description: "选择验证码的显示类型,不同类型有不同的安全级别",
type: "select",
key: "cloudflare_site_key",
label: "Cloudflare Site Key",
placeholder: "请输入 Cloudflare Site Key",
description: "Cloudflare Turnstile 站点密钥",
type: "input",
dependsOn: "captcha_enabled",
dependsValue: "true",
},
{
key: "cloudflare_secret_key",
label: "Cloudflare Secret Key",
placeholder: "请输入 Cloudflare Secret Key",
description: "Cloudflare Turnstile 密钥",
type: "input",
dependsOn: "captcha_enabled",
dependsValue: "true",
options: [
{
label: "随机类型",
value: "RANDOM",
description: "系统随机选择验证码类型",
},
{
label: "滑块验证码",
value: "SLIDER",
description: "拖动滑块完成拼图验证",
},
{
label: "文字点选验证码",
value: "WORD_IMAGE_CLICK",
description: "按顺序点击指定文字",
},
{
label: "旋转验证码",
value: "ROTATE",
description: "旋转图片到正确角度",
},
{
label: "拼图验证码",
value: "CONCAT",
description: "拖动滑块完成图片拼接",
},
],
},
];
@@ -110,7 +93,13 @@ const CONFIG_ITEMS: ConfigItem[] = [
const getInitialConfigs = (): Record<string, string> => {
if (typeof window === "undefined") return {};
const configKeys = ["app_name", "captcha_enabled", "captcha_type", "ip"];
const configKeys = [
"app_name",
"captcha_enabled",
"cloudflare_site_key",
"cloudflare_secret_key",
"ip",
];
const initialConfigs: Record<string, string> = {};
try {
@@ -193,14 +182,7 @@ export default function ConfigPage() {
// 处理配置项变更
const handleConfigChange = (key: string, value: string) => {
let newConfigs = { ...configs, [key]: value };
// 特殊处理:启用验证码时,如果验证码类型未设置,默认为随机
if (key === "captcha_enabled" && value === "true") {
if (!newConfigs.captcha_type) {
newConfigs.captcha_type = "RANDOM";
}
}
const newConfigs = { ...configs, [key]: value };
setConfigs(newConfigs);
+59 -219
View File
@@ -1,19 +1,16 @@
import { Button } from "@heroui/button";
import { Input } from "@heroui/input";
import { Card, CardBody, CardHeader } from "@heroui/card";
import { useState, useEffect, useRef } from "react";
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import toast from "react-hot-toast";
import axios from "axios";
import Turnstile from "react-turnstile";
import { isWebViewFunc } from "@/utils/panel";
import { siteConfig } from "@/config/site";
import { title } from "@/components/primitives";
import DefaultLayout from "@/layouts/default";
import { login, LoginData, checkCaptcha } from "@/api";
import "@/utils/tac.css";
import "@/utils/tac.min.js";
import bgImage from "@/images/bg.jpg";
import { login, LoginData, checkCaptcha, getConfigByName } from "@/api";
interface LoginForm {
username: string;
@@ -21,37 +18,6 @@ interface LoginForm {
captchaId: string;
}
interface CaptchaConfig {
requestCaptchaDataUrl: string;
validCaptchaUrl: string;
bindEl: string;
validSuccess: (res: any, captcha: any, tac: any) => void;
validFail?: (res: any, captcha: any, tac: any) => void;
btnCloseFun?: (event: any, tac: any) => void;
btnRefreshFun?: (event: any, tac: any) => void;
}
interface CaptchaStyle {
btnUrl?: string;
bgUrl?: string;
logoUrl?: string | null;
moveTrackMaskBgColor?: string;
moveTrackMaskBorderColor?: string;
}
interface CaptchaGeneratePayload {
id?: string;
captcha?: {
type?: string;
backgroundImage?: string;
templateImage?: string;
};
data?: {
id?: string;
};
success?: boolean;
}
export default function IndexPage() {
const [form, setForm] = useState<LoginForm>({
username: "",
@@ -61,93 +27,15 @@ export default function IndexPage() {
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<Partial<LoginForm>>({});
const [showCaptcha, setShowCaptcha] = useState(false);
const [siteKey, setSiteKey] = useState("");
const navigate = useNavigate();
const tacInstanceRef = useRef<any>(null);
const captchaContainerRef = useRef<HTMLDivElement>(null);
const [isWebView, setIsWebView] = useState(false);
// 清理验证码实例
useEffect(() => {
return () => {
if (tacInstanceRef.current) {
tacInstanceRef.current.destroyWindow();
tacInstanceRef.current = null;
}
};
}, []);
// 检测是否在WebView中运行
useEffect(() => {
setIsWebView(isWebViewFunc());
}, []);
const resolveCaptchaBaseURL = () =>
axios.defaults.baseURL ||
(import.meta.env.VITE_API_BASE
? `${import.meta.env.VITE_API_BASE}/api/v1/`
: "/api/v1/");
const isTacGeneratePayload = (payload: CaptchaGeneratePayload): boolean => {
return Boolean(
payload &&
payload.id &&
payload.captcha &&
typeof payload.captcha.type === "string" &&
payload.captcha.type.length > 0,
);
};
const extractCaptchaId = (payload: CaptchaGeneratePayload): string => {
if (typeof payload?.id === "string" && payload.id.trim()) {
return payload.id;
}
if (typeof payload?.data?.id === "string" && payload.data.id.trim()) {
return payload.data.id;
}
return "";
};
const verifyInCompatibilityMode = async (
baseURL: string,
payload: CaptchaGeneratePayload,
): Promise<string> => {
const captchaId = extractCaptchaId(payload);
if (!captchaId) {
throw new Error("验证码初始化失败");
}
const verifyResp = await axios.post(
`${baseURL}captcha/verify`,
{
captchaId,
trackData: JSON.stringify({ mode: "compat", ts: Date.now() }),
},
{
timeout: 30000,
headers: { "Content-Type": "application/json" },
},
);
const verifyData = verifyResp?.data || {};
const success =
verifyData.success === true ||
verifyData.code === 0 ||
verifyData.code === 200;
if (!success) {
throw new Error(verifyData.msg || verifyData.message || "验证码校验失败");
}
const validToken =
verifyData?.data?.validToken &&
typeof verifyData.data.validToken === "string"
? verifyData.data.validToken
: captchaId;
return validToken;
};
// 验证表单
const validateForm = (): boolean => {
const newErrors: Partial<LoginForm> = {};
@@ -176,86 +64,6 @@ export default function IndexPage() {
}
};
// 初始化验证码
const initCaptcha = async () => {
try {
// 清理之前的验证码实例
if (tacInstanceRef.current) {
tacInstanceRef.current.destroyWindow();
tacInstanceRef.current = null;
}
const baseURL = resolveCaptchaBaseURL();
const hasTacRenderer = Boolean(window.TAC && captchaContainerRef.current);
const generateResp = await axios.post<CaptchaGeneratePayload>(
`${baseURL}captcha/generate`,
{},
{
timeout: 30000,
headers: { "Content-Type": "application/json" },
},
);
const generatePayload = generateResp?.data || {};
if (!hasTacRenderer || !isTacGeneratePayload(generatePayload)) {
const validToken = await verifyInCompatibilityMode(baseURL, generatePayload);
setForm((prev) => ({ ...prev, captchaId: validToken }));
setShowCaptcha(false);
await performLogin(validToken);
return;
}
const config: CaptchaConfig = {
requestCaptchaDataUrl: `${baseURL}captcha/generate`,
validCaptchaUrl: `${baseURL}captcha/verify`,
bindEl: "#captcha-container",
validSuccess: (res: any, _: any, tac: any) => {
const validToken = res?.data?.validToken || "";
setForm((prev) => ({ ...prev, captchaId: validToken }));
setShowCaptcha(false);
tac.destroyWindow();
void performLogin(validToken);
},
validFail: (_: any, _captcha: any, tac: any) => {
tac.reloadCaptcha();
},
btnCloseFun: (_event: any, tac: any) => {
setShowCaptcha(false);
tac.destroyWindow();
setLoading(false);
},
btnRefreshFun: (_event: any, tac: any) => {
tac.reloadCaptcha();
},
};
// 检测暗黑模式
const isDarkMode =
document.documentElement.classList.contains("dark") ||
document.documentElement.getAttribute("data-theme") === "dark" ||
window.matchMedia("(prefers-color-scheme: dark)").matches;
// 根据主题调整颜色
const trackColor = isDarkMode ? "#4a5568" : "#7db0be"; // 暗黑模式使用更深的灰蓝色
const style: CaptchaStyle = {
bgUrl: bgImage,
logoUrl: null,
moveTrackMaskBgColor: trackColor,
moveTrackMaskBorderColor: trackColor,
};
tacInstanceRef.current = new window.TAC(config, style);
tacInstanceRef.current.init();
} catch {
toast.error("验证码初始化失败,请刷新页面重试");
setShowCaptcha(false);
setLoading(false);
}
};
// 执行登录请求
const performLogin = async (captchaToken?: string) => {
try {
@@ -274,7 +82,9 @@ export default function IndexPage() {
if (response.code !== 0) {
toast.error(response.msg || "登录失败");
if (showCaptcha) {
setForm((prev) => ({ ...prev, captchaId: "" }));
}
return;
}
@@ -324,15 +134,21 @@ export default function IndexPage() {
// 根据返回值决定是否显示验证码
if (checkResponse.data === 0) {
// 不需要验证码,直接登录
await performLogin();
} else {
// 需要验证码,显示验证码弹层
setShowCaptcha(true);
// 延时初始化验证码,确保DOM已渲染
setTimeout(() => {
initCaptcha();
}, 100);
const configResp = await getConfigByName("cloudflare_site_key");
if (
configResp.code === 0 &&
configResp.data &&
configResp.data.value
) {
setSiteKey(configResp.data.value);
setShowCaptcha(true);
} else {
toast.error("未配置Cloudflare Site Key,请联系管理员");
setLoading(false);
}
}
} catch (error) {
toast.error("网络错误,请稍后重试" + error);
@@ -422,26 +238,50 @@ export default function IndexPage() {
</div>
{/* 验证码弹层 */}
{showCaptcha && (
{showCaptcha && siteKey && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* 背景遮罩层 - 模糊效果,暗黑模式下更深 */}
<div className="absolute inset-0 bg-black/60 dark:bg-black/80 backdrop-blur-sm captcha-backdrop-enter" />
<div
className="absolute inset-0 bg-black/60 dark:bg-black/80 backdrop-blur-sm captcha-backdrop-enter"
role="button"
tabIndex={0}
onClick={() => {
setShowCaptcha(false);
setLoading(false);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
setShowCaptcha(false);
setLoading(false);
}
}}
/>
{/* 验证码容器 */}
<div className="mb-4">
<div
ref={captchaContainerRef}
className="w-full flex justify-center"
id="captcha-container"
style={{
filter:
<div className="mb-4 relative z-50 bg-white dark:bg-zinc-900 p-6 rounded-lg shadow-xl">
<div className="mb-4 text-center text-sm font-medium text-gray-700 dark:text-gray-200">请完成安全验证</div>
<div className="flex justify-center">
<Turnstile
sitekey={siteKey}
onVerify={(token) => {
setForm((prev) => ({ ...prev, captchaId: token }));
void performLogin(token);
}}
onError={() => {
toast.error("验证失败,请刷新重试");
setLoading(false);
}}
onExpire={() => {
setForm((prev) => ({ ...prev, captchaId: "" }));
}}
theme={
document.documentElement.classList.contains("dark") ||
document.documentElement.getAttribute("data-theme") ===
"dark" ||
document.documentElement.getAttribute("data-theme") === "dark" ||
window.matchMedia("(prefers-color-scheme: dark)").matches
? "brightness(0.8) contrast(0.9)"
: "none",
}}
/>
? "dark"
: "light"
}
/>
</div>
</div>
</div>
)}