DEV Community

likun
likun

Posted on

1

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

Image of Timescale

Timescale – the developer's data platform for modern apps, built on PostgreSQL

Timescale Cloud is PostgreSQL optimized for speed, scale, and performance. Over 3 million IoT, AI, crypto, and dev tool apps are powered by Timescale. Try it free today! No credit card required.

Try free

Top comments (0)

Heroku

Simplify your DevOps and maximize your time.

Since 2007, Heroku has been the go-to platform for developers as it monitors uptime, performance, and infrastructure concerns, allowing you to focus on writing code.

Learn More

👋 Kindness is contagious

Discover a treasure trove of wisdom within this insightful piece, highly respected in the nurturing DEV Community enviroment. Developers, whether novice or expert, are encouraged to participate and add to our shared knowledge basin.

A simple "thank you" can illuminate someone's day. Express your appreciation in the comments section!

On DEV, sharing ideas smoothens our journey and strengthens our community ties. Learn something useful? Offering a quick thanks to the author is deeply appreciated.

Okay