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: '' })