diff --git a/api/tenant/v1/auth.go b/api/tenant/v1/auth.go
index ee1bd98..122ad3a 100644
--- a/api/tenant/v1/auth.go
+++ b/api/tenant/v1/auth.go
@@ -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 {
diff --git a/internal/logic/common/captcha.go b/internal/logic/common/captcha.go
index 5eca1f1..6084b5d 100644
--- a/internal/logic/common/captcha.go
+++ b/internal/logic/common/captcha.go
@@ -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
+}
diff --git a/internal/logic/tenant/auth.go b/internal/logic/tenant/auth.go
index 7b5f957..4c4b99a 100644
--- a/internal/logic/tenant/auth.go
+++ b/internal/logic/tenant/auth.go
@@ -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
}
diff --git a/web/tenant/src/components/common/Turnstile.vue b/web/tenant/src/components/common/Turnstile.vue
new file mode 100644
index 0000000..d9cceb9
--- /dev/null
+++ b/web/tenant/src/components/common/Turnstile.vue
@@ -0,0 +1,154 @@
+
+
+
+
+
+
+
diff --git a/web/tenant/src/composables/usePublicSettings.ts b/web/tenant/src/composables/usePublicSettings.ts
index 04441e7..908bd35 100644
--- a/web/tenant/src/composables/usePublicSettings.ts
+++ b/web/tenant/src/composables/usePublicSettings.ts
@@ -24,6 +24,8 @@ const settings = ref({
site_description: '',
demo_mode: false,
demo_message: '',
+ turnstile_enabled: false,
+ turnstile_site_key: '',
})
let fetchPromise: Promise | null = null
diff --git a/web/tenant/src/views/auth/LoginPage.vue b/web/tenant/src/views/auth/LoginPage.vue
index 4ebcf9b..05de066 100644
--- a/web/tenant/src/views/auth/LoginPage.vue
+++ b/web/tenant/src/views/auth/LoginPage.vue
@@ -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 | null>(null)
const emailInput = ref(null)
diff --git a/web/tenant/src/views/auth/RegisterPage.vue b/web/tenant/src/views/auth/RegisterPage.vue
index 24468c8..7c44ab4 100644
--- a/web/tenant/src/views/auth/RegisterPage.vue
+++ b/web/tenant/src/views/auth/RegisterPage.vue
@@ -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 | null = null
const orgForm = reactive({ orgName: '', orgCode: '' })