在 Nextjs 中无缝集成 Cloudflare Turnstile

longlikun

likun

Posted on November 1, 2024

在 Nextjs 中无缝集成 Cloudflare Turnstile

还在为复杂的 CAPTCHA 验证困扰您的用户体验吗?Cloudflare Turnstile 为我们带来了一个优雅的解决方案。作为新一代的智能验证服务,它不仅能有效识别并拦截恶意机器人,更重要的是能让真实用户享受到丝滑般的网站体验。

为什么选择 Turnstile? 🚀 零感知验证 - 无需传统的图片识别验证码

🔒 强大的安全防护 - 智能识别并阻挡自动化攻击

🌐 通用性强 - 可在任何网站使用,不限于 Cloudflare 的站点

⚡ 性能出众 - 对网站性能影响微乎其微

这篇教程将带您一步步在 Next.js 项目中集成 Turnstile。虽然过程相对简单,但基于实践经验,我们会重点关注一些容易被忽视的技术细节,帮助您实现真正完美的集成。
这里的markdown排版不太好用,可以我的博客阅读体验会好一些

一.创建 Cloudflare 账户并配置 Turnstile 首先,您需要:

创建或登录 Cloudflare 账户--访问 Turnstile 控制面板--点击"添加小组件"创建新的验证组件

创建组件

在小部件设置中,添加您的域并选择“托管”模式,确保将其添加localhost到您的域以用于开发目的。

设置模式

二. 获取必要的密钥 在创建验证组件后,您将获得两个重要的密钥:

Site Key: 用于客户端集成

Secret Key: 用于服务端验证

获取密钥

三. 项目结构设计 在开始编码之前,让我们先了解一下完整的项目结构
带插入图片

四.初始化一个nextjs项目,并添加环境变量 在开始集成 Turnstile 之前,我们需要先创建并配置一个 Next.js 项目。

创建 Next.js 项目
使用 create-next-app 创建项目

npx create-next-app@latest my-turnstile-app
 cd my-turnstile-app
Enter fullscreen mode Exit fullscreen mode

添加env环境变量 在env文件中添加刚才获取的key

NEXT_PUBLIC_TURNSTILE_SITE_KEY=your_site_key_here TURNSTILE_SECRET_KEY=your_secret_key_here
五.定义接口 首先让我们定义一个接口组件,这个接口将作为我们的基础验证组件。它主要提供以下功能:

支持自定义验证栏的背景颜色(深色/浅色主题) 提供多语言支持,方便国际化 集成 Turnstile 的回调机制,处理验证结果

'use client';
import Script from 'next/script';
import { useCallback, useEffect, useRef } from 'react';

interface TurnstileWidgetProps {
  onVerify: (token: string) => void;
  theme?: 'light' | 'dark';
  language?: string;
}

const TurnstileWidget: React.FC<TurnstileWidgetProps> = ({
  onVerify,
  theme = 'dark',
  language = 'zh-CN'
}) => {
  const divRef = useRef<HTMLDivElement>(null);


  const renderWidget = useCallback(() => {
    if (divRef.current && window.turnstile) {
      window.turnstile.render(divRef.current, {
        sitekey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY as string,
        callback: onVerify,
        theme,
        language,
      });
    }
  }, [theme, language, onVerify]);
  useEffect(() => {

    // 只在 turnstile 可用时渲染
    if (window.turnstile) {
      renderWidget();
    }
    const currentRef = divRef.current;
    // 清理函数:组件卸载或依赖项改变时移除 widget
    return () => {
      if (currentRef) {
        window.turnstile?.remove(currentRef);
      }
    };
  }, [renderWidget, theme, language, onVerify]);

  return (<>
    <Script
      src="https://challenges.cloudflare.com/turnstile/v0/api.js"
      onLoad={
        renderWidget
      }
    />
    <div ref={divRef} />
  </>);
};

export default TurnstileWidget;
Enter fullscreen mode Exit fullscreen mode

这个接口组件使用了 Next.js 的 Script 组件来按需加载 Turnstile 的脚本,这样可以优化页面加载性能。同时,我们还实现了完整的生命周期管理,确保组件在卸载时能够正确清理资源。

六.定义表单组件 接下来,我们创建一个实际的表单组件来展示如何使用 Turnstile 验证。这个组件的主要特点包括:

集成了我们刚才创建的 Turnstile 接口组件 实现了完整的表单状态管理 提供了友好的加载和错误状态提示 使用 TypeScript 确保类型安全

'use client';
import { useState, FormEvent } from 'react';
import TurnstileWidget from './TurnstileWidget';

interface FormData {
  email: string;
  password: string;
}

interface ApiResponse {
  message?: string;
  error?: string;
}
// 添加状态类型
type StatusType = 'success' | 'error' | '';

const ContactForm = () => {
  const [formData, setFormData] = useState<FormData>({
    email: '',
    password: '',
  });
  const [turnstileToken, setTurnstileToken] = useState<string>('');
  const [status, setStatus] = useState<{ type: StatusType; message: string }>({
    type: '',
    message: ''
  });
  const [isSubmitting, setIsSubmitting] = useState<boolean>(false);

  const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    if (!turnstileToken) {
      setStatus({ type: 'error', message: '请完成人机验证' });
      return;
    }

    setIsSubmitting(true);

    try {
      const response = await fetch('/api/contact', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          ...formData,
          turnstileToken,
        }),
      });

      const data: ApiResponse = await response.json();

      if (response.ok) {
        setStatus({ type: 'success', message: '提交成功!' });
        setFormData({ email: '', password: '' });
      } else {
        setStatus({ type: 'error', message: `错误: ${data.error || '未知错误'}` });
      }
    } catch (error) {
      setStatus({ type: 'error', message: '提交失败,请重试' });
      console.error('Form submission error:', error);
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit} className="mx-auto mt-16 max-w-xl sm:mt-20">
    <div className="grid grid-cols-1 gap-x-8 gap-y-6 sm:grid-cols-2">
      <div className="sm:col-span-2">
        <label htmlFor="email" className="block text-sm/6 font-semibold text-gray-900">
          邮箱
        </label>
        <div className="mt-2.5">
          <input
            type="email"
            id="email"
            value={formData.email}
            onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
            required
            className="block w-full rounded-md border-0 px-3.5 py-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm/6"
          />
        </div>
      </div>
           <div className="sm:col-span-2">
        <label htmlFor="password" className="block text-sm/6 font-semibold text-gray-900">
          密码
        </label>
        <div className="mt-2.5">
          <input
            type="password"
            id="password"
            value={formData.password}
            onChange={(e) => setFormData(prev => ({ ...prev, password: e.target.value }))}
            required
            className="block w-full rounded-md border-0 px-3.5 py-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm/6"
          />
        </div>
      </div>      


      <div className="sm:col-span-2">
        <TurnstileWidget
          onVerify={setTurnstileToken}
          theme="dark"
          language='en'
        />
      </div>

      {status.message && (
        <div className="sm:col-span-2">
          <p className={`text-sm ${status.type === 'error' ? 'text-red-600' : 'text-green-600'}`}>
            {status.message}
          </p>
        </div>
      )}
    </div>

    <div className="mt-10">
      <button
        type="submit"
        disabled={isSubmitting}
        className={`block w-full rounded-md px-3.5 py-2.5 text-center text-sm font-semibold text-white shadow-sm ${
          isSubmitting
            ? 'bg-indigo-400 cursor-not-allowed'
            : 'bg-indigo-600 hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600'
        }`}
      >
        {isSubmitting ? '提交中...' : '提交'}
      </button>
    </div>
  </form>


  );
};

export default ContactForm;
Enter fullscreen mode Exit fullscreen mode

组件中包含了一些关键的状态处理,表单数据状态管理,验证token的存储和处理,提交状态的追踪,错误信息的展示

七.定义 API 路由
为了处理表单提交,我们需要创建一个后端 API 路由。这个路由主要负责:

接收前端提交的表单数据和验证token
与 Cloudflare 服务器通信验证 token 的有效性
处理验证结果并返回适当的响应
然后定义一个API 路由类型和实现

interface TurnstileVerificationResponse {
    success: boolean;
    error_codes?: string[];
    challenge_ts?: string;
    hostname?: string;
}

interface RequestBody extends FormData {
    turnstileToken: string;
}

export async function POST(request: Request) {
    try {
        const body: RequestBody = await request.json();
        const { turnstileToken, ...formData } = body;

        // 验证 Turnstile token
        const verificationResponse = await fetch(
            'https://challenges.cloudflare.com/turnstile/v0/siteverify',
            {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({
                    secret: process.env.TURNSTILE_SECRET_KEY,
                    response: turnstileToken,
                }),
            }
        );

        const verificationData: TurnstileVerificationResponse = await verificationResponse.json();

        if (!verificationData.success) {
            console.error('Turnstile verification failed:', verificationData['error_codes']);
            return Response.json(
                { error: '验证码验证失败' },
                { status: 400 }
            );
        }

        // 验证通过,处理表单数据
        // 这里添加你的业务逻辑,比如发送邮件或保存到数据库
        console.log(formData)

        return Response.json({ message: '提交成功' });
    } catch (error) {
        console.error('API route error:', error);
        return Response.json(
            { error: '服务器错误' },
            { status: 500 }
        );
    }
}

Enter fullscreen mode Exit fullscreen mode

在这个部分,我们重点关注了几个安全相关的问题:

确保所有请求都经过 Turnstile 验证,妥善处理验证失败的情况,提供清晰的错误信息.

八.创建测试页面
最后,我们创建一个测试页面来集成所有组件。

import ContactForm from "../components/ContactForm";

export default function ContactPage() {
  return (
    <div className="container mx-auto p-4">
      <h1 className="text-2xl font-bold mb-4">联系我们</h1>
      <ContactForm />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

九.本地运行测试

运行测试

结语
原文通过这个教程,我们不仅完成了 Turnstile 的基础集成,更重要的是掌握了一些实用的开发技巧。希望这些内容能帮助您在项目中构建更安全、更友好的用户验证机制。在实际部署时候,需要注意确保环境变量正确配置,检查域名设置是否包含了所有需要的域名. 如果您在集成过程中遇到任何问题,欢迎在评论区留言讨论。

💖 💪 🙅 🚩
longlikun
likun

Posted on November 1, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related