feat:添加Turnstile验证

This commit is contained in:
chengqian committed 2026-06-09 18:51:45 +08:00
1 parent 13073babc0
commit e5e7080ddf
7 files changed
+231 -16

No files matched your search

+17 -15
View File
@@ -4,15 +4,16 @@ import "github.com/gogf/gf/v2/frame/g"
// TenantRegisterReq 租户注册请求
type TenantRegisterReq struct {
g.Meta `path:"/auth/register" method:"post" mime:"json" tags:"租户控制台-认证" summary:"租户注册" group:"public" middleware:"-"`
Email string `json:"email" v:"required|email#请输入邮箱|邮箱格式不正确" dc:"邮箱"`
Code string `json:"code" dc:"邮箱验证码(邮箱验证开启时必填)"`
Password string `json:"password" v:"required|length:8,64#请输入密码|密码长度为8-64位" dc:"密码"`
TenantName string `json:"tenant_name" v:"required|length:2,100#请输入组织名称|组织名称长度为2-100位" dc:"组织名称"`
TenantCode string `json:"tenant_code" v:"required|length:3,30|regex:^[a-z0-9][a-z0-9-]*[a-z0-9]$#请输入组织代码|组织代码为3-30位小写字母数字|组织代码格式不正确" dc:"组织代码"`
Username string `json:"username" v:"required|length:3,50#请输入用户名|用户名长度为3-50位" dc:"用户名"`
CaptchaKey string `json:"captcha_key" dc:"滑块验证码key(邮箱验证关闭时必填)"`
CaptchaX int `json:"captcha_x" dc:"滑块X坐标(邮箱验证关闭时必填)"`
g.Meta `path:"/auth/register" method:"post" mime:"json" tags:"租户控制台-认证" summary:"租户注册" group:"public" middleware:"-"`
Email string `json:"email" v:"required|email#请输入邮箱|邮箱格式不正确" dc:"邮箱"`
Code string `json:"code" dc:"邮箱验证码(邮箱验证开启时必填)"`
Password string `json:"password" v:"required|length:8,64#请输入密码|密码长度为8-64位" dc:"密码"`
TenantName string `json:"tenant_name" v:"required|length:2,100#请输入组织名称|组织名称长度为2-100位" dc:"组织名称"`
TenantCode string `json:"tenant_code" v:"required|length:3,30|regex:^[a-z0-9][a-z0-9-]*[a-z0-9]$#请输入组织代码|组织代码为3-30位小写字母数字|组织代码格式不正确" dc:"组织代码"`
Username string `json:"username" v:"required|length:3,50#请输入用户名|用户名长度为3-50位" dc:"用户名"`
CaptchaKey string `json:"captcha_key" dc:"滑块验证码key(Turnstile关闭时必填)"`
CaptchaX int `json:"captcha_x" dc:"滑块X坐标(Turnstile关闭时必填)"`
TurnstileToken string `json:"turnstile_token" dc:"Cloudflare Turnstile验证token(Turnstile启用时必填)"`
}
type TenantRegisterRes struct {
@@ -33,12 +34,13 @@ type TenantRegisterRes struct {
// TenantLoginReq 租户登录请求
type TenantLoginReq struct {
g.Meta `path:"/auth/login" method:"post" mime:"json" tags:"租户控制台-认证" summary:"租户登录" group:"public" middleware:"-"`
Account string `json:"account" v:"required#请输入账号" dc:"账号(username@tenant_code 或邮箱)"`
Password string `json:"password" v:"required#请输入密码" dc:"密码"`
Type string `json:"type" v:"required|in:ram,admin#请选择登录方式|登录方式无效" dc:"登录方式:ram(RAM账号)/ admin(管理员邮箱)"`
CaptchaKey string `json:"captcha_key" dc:"验证码key(验证码启用时必填)"`
CaptchaX int `json:"captcha_x" dc:"滑块X坐标(验证码启用时必填)"`
g.Meta `path:"/auth/login" method:"post" mime:"json" tags:"租户控制台-认证" summary:"租户登录" group:"public" middleware:"-"`
Account string `json:"account" v:"required#请输入账号" dc:"账号(username@tenant_code 或邮箱)"`
Password string `json:"password" v:"required#请输入密码" dc:"密码"`
Type string `json:"type" v:"required|in:ram,admin#请选择登录方式|登录方式无效" dc:"登录方式:ram(RAM账号)/ admin(管理员邮箱)"`
CaptchaKey string `json:"captcha_key" dc:"验证码key(Turnstile关闭时必填)"`
CaptchaX int `json:"captcha_x" dc:"滑块X坐标(Turnstile关闭时必填)"`
TurnstileToken string `json:"turnstile_token" dc:"Cloudflare Turnstile验证token(Turnstile启用时必填)"`
}
type TenantLoginRes struct {
+44
View File
@@ -15,6 +15,7 @@ import (
"github.com/wenlng/go-captcha/v2/slide"
"github.com/qianfree/team-api/internal/consts"
"github.com/qianfree/team-api/internal/utility/turnstile"
)
const (
@@ -211,3 +212,46 @@ func getCaptchaExpireSeconds(ctx context.Context) time.Duration {
}
return time.Duration(secs) * time.Second
}
// CheckTurnstileRequired checks Cloudflare Turnstile verification when enabled.
// If Turnstile is disabled, passes through (allows fallback to captcha).
func CheckTurnstileRequired(ctx context.Context, token string) error {
// Check if Turnstile is enabled
enabled := Config().GetBool(ctx, "turnstile_enabled")
if !enabled {
// Turnstile not enabled, allow pass-through to captcha
return nil
}
// Turnstile is enabled, token is required
if token == "" {
return NewBusinessError(consts.CodeCaptchaRequired, consts.MsgCaptchaRequired)
}
// Verify token with Cloudflare
secretKey := Config().GetString(ctx, "turnstile_secret_key")
if secretKey == "" {
g.Log().Errorf(ctx, "[Turnstile] secret_key not configured")
return NewBusinessError(consts.CodeInternalServerError, "验证服务配置错误")
}
// Get client IP for verification
ipAddress := ""
if r := g.RequestFromCtx(ctx); r != nil {
ipAddress = r.GetClientIp()
}
result, err := turnstile.Verify(ctx, secretKey, token, ipAddress)
if err != nil {
g.Log().Errorf(ctx, "[Turnstile] verification failed: %v", err)
return NewBusinessError(consts.CodeCaptchaFailed, "人机验证失败")
}
if !result.Success {
g.Log().Warningf(ctx, "[Turnstile] verification failed: error codes=%v", result.ErrorCodes)
return NewBusinessError(consts.CodeCaptchaFailed, "人机验证失败")
}
// Verification successful
return nil
}
+10 -1
View File
@@ -58,7 +58,12 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
return nil, err
}
} else {
// 滑块验证模式
// 人机验证:优先 Turnstile,否则滑块验证
if err := common.CheckTurnstileRequired(ctx, req.TurnstileToken); err != nil {
return nil, err
}
// Turnstile 未启用时,使用滑块验证
if err := common.CheckCaptchaRequired(ctx, "tenant_register", req.CaptchaKey, req.CaptchaX); err != nil {
return nil, err
}
@@ -188,6 +193,10 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant
account := strings.TrimSpace(req.Account)
// Check captcha if required
if err := common.CheckTurnstileRequired(ctx, req.TurnstileToken); err != nil {
return nil, err
}
if err := common.CheckCaptchaRequired(ctx, "tenant_login", req.CaptchaKey, req.CaptchaX); err != nil {
return nil, err
}
@@ -0,0 +1,154 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
interface Props {
modelValue?: string
siteKey?: string
theme?: 'light' | 'dark' | 'auto'
size?: 'normal' | 'compact'
tabindex?: number
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
siteKey: '',
theme: 'auto',
size: 'normal',
tabindex: 0,
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'verified', token: string): void
(e: 'error', error: any): void
}>()
const containerId = `turnstile-container-${Math.random().toString(36).substr(2, 9)}`
const isLoaded = ref(false)
const isVerifying = ref(false)
const turnstileScriptId = 'turnstile-script'
let widgetId: string | null = null
// Load Cloudflare Turnstile script
function loadTurnstileScript(): Promise<void> {
return new Promise((resolve, reject) => {
if (window.turnstile) {
isLoaded.value = true
resolve()
return
}
const script = document.createElement('script')
script.id = turnstileScriptId
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'
script.async = true
script.defer = true
script.onload = () => {
isLoaded.value = true
resolve()
}
script.onerror = () => {
reject(new Error('Failed to load Turnstile script'))
}
document.head.appendChild(script)
})
}
// Render Turnstile widget
function renderWidget() {
if (!window.turnstile || !props.siteKey) return
const container = document.getElementById(containerId)
if (!container) return
// Clear existing widget
if (widgetId !== null) {
window.turnstile.remove(widgetId)
}
// Render new widget
widgetId = window.turnstile.render(container, {
sitekey: props.siteKey,
theme: props.theme,
size: props.size,
tabindex: props.tabindex,
callback: (token: string) => {
isVerifying.value = false
emit('update:modelValue', token)
emit('verified', token)
},
'error-callback': (error: any) => {
isVerifying.value = false
emit('error', error)
},
'expired-callback': () => {
widgetId = null
emit('update:modelValue', '')
},
})
}
// Reset widget
function reset() {
if (widgetId !== null && window.turnstile) {
window.turnstile.reset(widgetId)
emit('update:modelValue', '')
}
}
// Expose reset method
defineExpose({
reset,
})
onMounted(async () => {
try {
await loadTurnstileScript()
renderWidget()
} catch (error) {
console.error('Failed to initialize Turnstile:', error)
emit('error', error)
}
})
onUnmounted(() => {
if (widgetId !== null && window.turnstile) {
window.turnstile.remove(widgetId)
}
})
// Watch for siteKey changes
watch(() => props.siteKey, () => {
if (isLoaded.value) {
renderWidget()
}
})
// Watch for modelValue changes from parent
watch(() => props.modelValue, (newValue) => {
if (!newValue && widgetId !== null && window.turnstile) {
window.turnstile.reset(widgetId)
}
})
</script>
<template>
<div
:id="containerId"
class="turnstile-widget"
:class="{ 'turnstile-loading': isVerifying }"
/>
</template>
<style scoped>
.turnstile-widget {
min-height: 65px;
display: flex;
align-items: center;
justify-content: center;
}
.turnstile-loading {
opacity: 0.6;
}
</style>
@@ -24,6 +24,8 @@ const settings = ref<PublicSettings>({
site_description: '',
demo_mode: false,
demo_message: '',
turnstile_enabled: false,
turnstile_site_key: '',
})
let fetchPromise: Promise<void> | null = null
+2
View File
@@ -8,6 +8,7 @@ import request from '@/utils/request'
import AuthLayout from '@/components/layout/AuthLayout.vue'
import Icon from '@/components/common/Icon.vue'
import SlideCaptcha from '@/components/common/SlideCaptcha.vue'
import Turnstile from '@/components/common/Turnstile.vue
const router = useRouter()
const route = useRoute()
@@ -35,6 +36,7 @@ const totpLoading = ref(false)
// Captcha state — always required
const captcha = reactive({ captchaKey: '', captchaX: 0 })
const turnstileToken = ref('')
const captchaRef = ref<InstanceType<typeof SlideCaptcha> | null>(null)
const emailInput = ref<HTMLInputElement | null>(null)
@@ -5,6 +5,7 @@ import { useTenantAuthStore } from '@/stores/tenant-auth'
import { usePublicSettings } from '@/composables/usePublicSettings'
import AuthLayout from '@/components/layout/AuthLayout.vue'
import SlideCaptcha from '@/components/common/SlideCaptcha.vue'
import Turnstile from '@/components/common/Turnstile.vue
import Icon from '@/components/common/Icon.vue'
import request from '@/utils/request'
import { extractApiError } from '@/utils/request'
@@ -19,6 +20,7 @@ const codeSending = ref(false)
const countdown = ref(0)
const emailVerification = ref(true)
const captcha = ref<{ captchaKey: string; captchaX: number }>({ captchaKey: '', captchaX: 0 })
const turnstileToken = ref('')
let countdownTimer: ReturnType<typeof setInterval> | null = null
const orgForm = reactive({ orgName: '', orgCode: '' })