feat:添加Turnstile验证
This commit is contained in:
1 parent
13073babc0
commit
e5e7080ddf
7 files changed
+231
-16
No files matched your search
+17
-15
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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: '' })
|
||||
|
||||
Reference in new issue
Block a user