返回功能模块设计实现
功能模块设计实现 / 发布 2026-06-20 14:50 / 更新 2026-06-21 01:50

Cloudflare Turnstile 免费人机验证接入

整理 Cloudflare Turnstile 免费人机验证的控制台创建、前端组件接入和后端 token 校验配置。

CloudflareTurnstile人机验证验证码免费功能模块设计实现
Cloudflare Turnstile 免费人机验证接入

适用场景

Cloudflare Turnstile 用于替代传统图片验证码,常见接入位置包括登录、注册、发送邮箱验证码、提交表单等容易被机器人刷接口的入口。

  • 前端展示 Turnstile 组件,拿到一次性的 token
  • 后端使用 Secret key 调用 Cloudflare siteverify 接口验证 token。
  • 校验通过后再继续执行登录、发送邮件或其他受保护业务。

创建 Turnstile Widget

进入 Turnstile 页面

在 Cloudflare 控制台左侧菜单中打开 Application security,再进入 Turnstile

Cloudflare 控制台 Turnstile 菜单入口

新建 Widget

进入 Turnstile widgets 页面后,点击 Add widget manually

Turnstile widgets 页面手动添加入口

填写 widget 名称,例如 login,然后在 Hostname Management 中添加允许使用该 widget 的域名。

填写 Turnstile widget 名称并进入 hostname 管理

开发环境可以临时添加 localhost。生产环境建议添加真实业务域名,例如 <YOUR_DOMAIN>

添加 localhost hostname

确认 hostname 已加入后点击 Save

保存 Turnstile hostname 配置

选择验证模式

Widget Mode 推荐先使用 Managed,由 Cloudflare 根据访问风险自动决定是否需要用户交互。

选择 Managed 模式并创建 Turnstile widget

创建完成后复制 Site keySecret key

复制 Turnstile Site key 和 Secret key

Site key 可以放到前端配置中;Secret key 只能保存在后端或环境变量里,不能提交到前端仓库。

前端接入

环境变量

前端只配置 Site key

VITE_TURNSTILE_SITE_KEY=<TURNSTILE_SITE_KEY>

本地开发和自动化测试可以使用 Cloudflare 官方测试 Site key,不用把真实 Site key 配到测试环境。

测试 Site key 结果 Widget 类型
1x00000000000000000000AA 永远通过 Visible
2x00000000000000000000AB 永远失败 Visible
1x00000000000000000000BB 永远通过 Invisible
2x00000000000000000000BB 永远失败 Invisible
3x00000000000000000000FF 强制交互挑战 Visible

Vue 组件示例

下面组件会动态加载 Cloudflare Turnstile 脚本,渲染成功后通过 v-model 回传 token。

<template>
  <div class="turnstile-widget">
    <div v-if="missingSiteKey" class="turnstile-widget__message">
      {{ unavailableText }}
    </div>
    <div v-else ref="containerRef" class="turnstile-widget__container"></div>
  </div>
</template>

<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'

defineOptions({ name: 'TurnstileWidget' })

const TURNSTILE_SCRIPT_ID = 'cloudflare-turnstile-script'
const TURNSTILE_SCRIPT_SRC =
  'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'

interface TurnstileRenderConfig {
  sitekey: string
  theme?: 'light' | 'dark' | 'auto'
  size?: 'normal' | 'compact' | 'flexible'
  action?: string
  language?: string
  callback?: (token: string) => void
  'expired-callback'?: () => void
  'error-callback'?: (errorCode?: string) => void
}

interface TurnstileClient {
  render(container: string | HTMLElement, options: TurnstileRenderConfig): string
  reset(widgetId?: string): void
  remove(widgetId: string): void
}

declare global {
  interface Window {
    turnstile?: TurnstileClient
  }
}

let turnstileLoadPromise: Promise<TurnstileClient> | null = null

interface Props {
  modelValue?: string
  siteKey?: string
  theme?: 'light' | 'dark' | 'auto'
  size?: 'normal' | 'compact' | 'flexible'
  action?: string
  language?: string
  unavailableText?: string
}

const props = withDefaults(defineProps<Props>(), {
  modelValue: '',
  siteKey: '',
  theme: 'auto',
  size: 'normal',
  action: 'send_magic_link',
  language: 'auto',
  unavailableText: 'Cloudflare Turnstile site key is not configured'
})

const emit = defineEmits<{
  (event: 'update:modelValue', value: string): void
  (event: 'verified', value: string): void
  (event: 'expired'): void
  (event: 'error', value?: string): void
  (event: 'loaded'): void
}>()

const containerRef = ref<HTMLElement>()
const widgetId = ref('')
const currentSiteKey = computed(
  () => props.siteKey || import.meta.env.VITE_TURNSTILE_SITE_KEY || ''
)
const missingSiteKey = computed(() => !currentSiteKey.value)

function loadTurnstile(): Promise<TurnstileClient> {
  if (window.turnstile) return Promise.resolve(window.turnstile)
  if (turnstileLoadPromise) return turnstileLoadPromise

  turnstileLoadPromise = new Promise((resolve, reject) => {
    const existingScript = document.getElementById(
      TURNSTILE_SCRIPT_ID
    ) as HTMLScriptElement | null
    const script = existingScript || document.createElement('script')

    const handleLoad = () => {
      if (window.turnstile) {
        resolve(window.turnstile)
        return
      }
      turnstileLoadPromise = null
      reject(new Error('Cloudflare Turnstile API is unavailable'))
    }

    const handleError = () => {
      turnstileLoadPromise = null
      reject(new Error('Failed to load Cloudflare Turnstile script'))
    }

    script.addEventListener('load', handleLoad, { once: true })
    script.addEventListener('error', handleError, { once: true })

    if (!existingScript) {
      script.id = TURNSTILE_SCRIPT_ID
      script.src = TURNSTILE_SCRIPT_SRC
      script.async = true
      script.defer = true
      document.head.appendChild(script)
    }
  })

  return turnstileLoadPromise
}

function clearToken() {
  emit('update:modelValue', '')
}

function removeWidget() {
  if (!widgetId.value || !window.turnstile) return

  try {
    window.turnstile.remove(widgetId.value)
  } catch (error) {
    console.warn('Failed to remove Cloudflare Turnstile widget:', error)
  } finally {
    widgetId.value = ''
  }
}

async function renderWidget() {
  if (missingSiteKey.value || !containerRef.value) return

  try {
    const turnstile = await loadTurnstile()
    await nextTick()
    if (!containerRef.value) return

    removeWidget()
    widgetId.value = turnstile.render(containerRef.value, {
      sitekey: currentSiteKey.value,
      theme: props.theme,
      size: props.size,
      action: props.action,
      language: props.language,
      callback: (token: string) => {
        emit('update:modelValue', token)
        emit('verified', token)
      },
      'expired-callback': () => {
        clearToken()
        emit('expired')
      },
      'error-callback': (errorCode?: string) => {
        clearToken()
        emit('error', errorCode)
      }
    })
    emit('loaded')
  } catch (error: any) {
    clearToken()
    emit('error', error?.message || 'Failed to load Cloudflare Turnstile')
  }
}

function reset() {
  clearToken()
  if (!widgetId.value || !window.turnstile) return
  window.turnstile.reset(widgetId.value)
}

onMounted(renderWidget)

onBeforeUnmount(() => {
  removeWidget()
  clearToken()
})

watch(
  () => [currentSiteKey.value, props.theme, props.size, props.action, props.language],
  () => {
    clearToken()
    renderWidget()
  }
)

defineExpose({ reset })
</script>

后端校验

配置项

后端使用 Secret key 调用 Cloudflare 校验接口。生产环境必须通过环境变量注入真实密钥。

turnstile:
  enabled: ${TURNSTILE_ENABLED:true}
  secret-key: ${TURNSTILE_SECRET_KEY:<TURNSTILE_SECRET_KEY>}
  verify-url: ${TURNSTILE_VERIFY_URL:https://challenges.cloudflare.com/turnstile/v0/siteverify}
  connect-timeout-millis: ${TURNSTILE_CONNECT_TIMEOUT_MILLIS:3000}
  read-timeout-millis: ${TURNSTILE_READ_TIMEOUT_MILLIS:5000}

本地测试可以使用 Cloudflare 官方测试密钥,避免把真实 Secret 写进配置文件。

测试 Secret Key 结果
1x0000000000000000000000000000000AA 永远通过
2x0000000000000000000000000000000AA 永远失败
3x0000000000000000000000000000000AA 模拟 token 已被使用

推荐本地测试组合如下。

场景 前端测试 Site key 后端测试 Secret key 预期结果
成功流程 1x00000000000000000000AA 1x0000000000000000000000000000000AA success=true
失败流程 2x00000000000000000000AB 2x0000000000000000000000000000000AA success=false
重复 token 1x00000000000000000000AA 3x0000000000000000000000000000000AA timeout-or-duplicate

测试 Site key 会生成 dummy token;生产 Secret key 会拒绝 dummy token。因此本地测试时前端 Site key 和后端 Secret key 要同时切换为测试密钥。

Spring Boot 配置类

package com.draft.auth.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

@Data
@Configuration
@ConfigurationProperties(prefix = "turnstile")
public class TurnstileProperties
{
    private boolean enabled = true;
    private String secretKey;
    private String verifyUrl = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
    private int connectTimeoutMillis = 3000;
    private int readTimeoutMillis = 5000;
}

校验服务

package com.draft.auth.service;

import com.draft.auth.config.TurnstileProperties;
import com.draft.auth.domain.TurnstileVerifyResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;

@Slf4j
@Service
public class TurnstileService
{
    private final TurnstileProperties turnstileProperties;
    private final RestTemplate restTemplate;

    public TurnstileService(TurnstileProperties turnstileProperties)
    {
        this.turnstileProperties = turnstileProperties;
        SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
        requestFactory.setConnectTimeout(turnstileProperties.getConnectTimeoutMillis());
        requestFactory.setReadTimeout(turnstileProperties.getReadTimeoutMillis());
        this.restTemplate = new RestTemplate(requestFactory);
    }

    public String verifyToken(String token, String remoteIp)
    {
        if (!turnstileProperties.isEnabled()) {
            return null;
        }
        if (!StringUtils.hasText(token)) {
            return "人机验证不能为空";
        }
        if (!StringUtils.hasText(turnstileProperties.getSecretKey())) {
            log.error("Turnstile 校验已开启,但未配置 secret-key");
            return "人机验证服务未配置";
        }

        HttpEntity<MultiValueMap<String, String>> requestEntity =
                buildVerifyRequest(token, remoteIp);

        try {
            ResponseEntity<TurnstileVerifyResponse> responseEntity = restTemplate.postForEntity(
                    turnstileProperties.getVerifyUrl(),
                    requestEntity,
                    TurnstileVerifyResponse.class
            );
            return buildVerifyError(responseEntity.getBody());
        } catch (RestClientException ex) {
            log.warn("Turnstile 远端校验失败: {}", ex.getMessage());
            return "人机验证服务暂不可用";
        }
    }

    private HttpEntity<MultiValueMap<String, String>> buildVerifyRequest(
            String token,
            String remoteIp
    ) {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

        MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
        form.add("secret", turnstileProperties.getSecretKey());
        form.add("response", token);
        if (StringUtils.hasText(remoteIp)) {
            form.add("remoteip", remoteIp);
        }

        return new HttpEntity<>(form, headers);
    }

    private String buildVerifyError(TurnstileVerifyResponse response)
    {
        if (response == null) {
            return "人机验证失败";
        }
        if (Boolean.TRUE.equals(response.getSuccess())) {
            return null;
        }

        log.warn("Turnstile 校验未通过,错误码: {}", response.getErrorCodes());
        return "人机验证失败";
    }
}

响应对象

package com.draft.auth.domain;

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.util.List;

@Data
public class TurnstileVerifyResponse
{
    private Boolean success;

    @JsonProperty("error-codes")
    private List<String> errorCodes;
}

接入流程

  1. 前端页面渲染 TurnstileWidget
  2. 用户通过 Turnstile 后,组件拿到 token
  3. 前端把 token 随登录、注册或发送验证码请求一起提交给后端。
  4. 后端调用 https://challenges.cloudflare.com/turnstile/v0/siteverify
  5. success=true 才继续执行业务;失败时返回统一提示。

常见问题

Secret key 能不能放前端

不能。Secret key 等价于后端校验凭据,只能放在后端环境变量或配置中心中。

本地 localhost 怎么测试

如果使用真实 Site key,开发环境可以在 Turnstile widget 的 hostname 中添加 localhost。如果使用 Cloudflare 官方测试 Site key / Secret key,则可以直接用于 localhost127.0.0.1 或其他开发域名,不需要先在控制台添加 hostname。

token 是否可以重复使用

不能。Turnstile token 是一次性校验结果,应当在业务请求中立即提交后端验证,后端验证后不应复用。

参考链接