diff --git a/internal/logic/common/config.go b/internal/logic/common/config.go index e31ff68..b55d87e 100644 --- a/internal/logic/common/config.go +++ b/internal/logic/common/config.go @@ -197,9 +197,18 @@ func (s *ConfigService) SetOption(ctx context.Context, key, value string) error } if count > 0 { + data := do.SysOptions{Value: value} + // Sync metadata from registry so that changes to IsPublic/Category/Description + // are reflected in the DB row on the next write (e.g. a setting newly marked + // as IsPublic will become visible to GetPublicOptions). + if def := GetSettingDef(key); def != nil { + data.Category = def.Category + data.Description = def.Label + data.IsPublic = def.IsPublic + } _, err = dao.SysOptions.Ctx(ctx). Where("key", key). - Data(do.SysOptions{Value: value}). + Data(data). Update() } else { def := GetSettingDef(key) diff --git a/internal/logic/common/settings_registry.go b/internal/logic/common/settings_registry.go index 84dd689..8aa471e 100644 --- a/internal/logic/common/settings_registry.go +++ b/internal/logic/common/settings_registry.go @@ -120,9 +120,9 @@ var Registry = []SettingDef{ {Key: "password_min_length", Type: SettingTypeInt, Default: "8", Category: "security", Label: "密码最小长度", Validation: "min:6,max:32"}, {Key: "turnstile_enabled", Type: SettingTypeBool, Default: "false", Category: "security", - Label: "启用 Turnstile 人机验证"}, + Label: "启用 Turnstile 人机验证", IsPublic: true}, {Key: "turnstile_site_key", Type: SettingTypeString, Default: "", Category: "security", - Label: "Turnstile Site Key", Sensitive: false}, + Label: "Turnstile Site Key", Sensitive: false, IsPublic: true}, {Key: "turnstile_secret_key", Type: SettingTypeString, Default: "", Category: "security", Label: "Turnstile Secret Key", Sensitive: true}, {Key: "captcha_expire_seconds", Type: SettingTypeInt, Default: "300", Category: "security", diff --git a/internal/logic/tenant/auth.go b/internal/logic/tenant/auth.go index 4fb20ab..7fc2c3d 100644 --- a/internal/logic/tenant/auth.go +++ b/internal/logic/tenant/auth.go @@ -70,14 +70,16 @@ 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 + // 人机验证:Turnstile 启用时只验 Turnstile,否则用滑块验证 + turnstileEnabled := common.Config().GetBool(ctx, "turnstile_enabled") + if turnstileEnabled { + if err := common.CheckTurnstileRequired(ctx, req.TurnstileToken); err != nil { + return nil, err + } + } else { + if err := common.CheckCaptchaRequired(ctx, "tenant_register", req.CaptchaKey, req.CaptchaX); err != nil { + return nil, err + } } } @@ -240,13 +242,16 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1. func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.TenantLoginRes, error) { 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 + // Check captcha: Turnstile takes priority; slider captcha is fallback + turnstileEnabled := common.Config().GetBool(ctx, "turnstile_enabled") + if turnstileEnabled { + if err := common.CheckTurnstileRequired(ctx, req.TurnstileToken); err != nil { + return nil, err + } + } else { + if err := common.CheckCaptchaRequired(ctx, "tenant_login", req.CaptchaKey, req.CaptchaX); err != nil { + return nil, err + } } // Get client info early for login history recording diff --git a/migrations/000009_sync_options_is_public.sql b/migrations/000009_sync_options_is_public.sql new file mode 100644 index 0000000..5d93dcb --- /dev/null +++ b/migrations/000009_sync_options_is_public.sql @@ -0,0 +1,27 @@ +-- +goose Up +-- Sync is_public flag from code registry to DB rows. +-- Settings like turnstile_enabled/turnstile_site_key were marked IsPublic in the +-- registry but the DB rows still had is_public=false, causing GetPublicOptions +-- (WHERE is_public=true) to skip them and return stale defaults to the tenant frontend. + +UPDATE sys_options SET is_public = true, updated_at = NOW() +WHERE key IN ( + 'site_name', + 'site_description', + 'register_enabled', + 'register_email_verification', + 'maintenance_message', + 'maintenance_duration', + 'api_maintenance_enabled', + 'oauth_auto_register', + 'oauth_github_enabled', + 'oauth_google_enabled', + 'turnstile_enabled', + 'turnstile_site_key', + 'agreement_enabled' +) AND is_public = false; + +-- +goose Down +-- No-op: reverting is_public to false would break public settings again, +-- so we leave the corrected values in place. +SELECT 1; diff --git a/web/tenant/src/stores/tenant-auth.ts b/web/tenant/src/stores/tenant-auth.ts index 8f5cb61..568c00d 100644 --- a/web/tenant/src/stores/tenant-auth.ts +++ b/web/tenant/src/stores/tenant-auth.ts @@ -114,13 +114,14 @@ export const useTenantAuthStore = defineStore('tenant-auth', () => { persist() } - async function login(account: string, password: string, type: 'ram' | 'admin', captcha?: { captchaKey: string; captchaX: number }): Promise { + async function login(account: string, password: string, type: 'ram' | 'admin', captcha?: { captchaKey: string; captchaX: number; turnstileToken?: string }): Promise { const { data } = await request.post('/tenant/auth/login', { account, password, type, captcha_key: captcha?.captchaKey, captcha_x: captcha?.captchaX, + turnstile_token: captcha?.turnstileToken, }) if (data.data?.totp_required) { return data.data diff --git a/web/tenant/src/views/auth/LoginPage.vue b/web/tenant/src/views/auth/LoginPage.vue index ac7018d..abf5540 100644 --- a/web/tenant/src/views/auth/LoginPage.vue +++ b/web/tenant/src/views/auth/LoginPage.vue @@ -36,10 +36,13 @@ const provisionalToken = ref('') const totpCode = ref('') const totpLoading = ref(false) -// Captcha state — always required +// Captcha state const captcha = reactive({ captchaKey: '', captchaX: 0 }) - const turnstileToken = ref('') +const turnstileToken = ref('') const captchaRef = ref | null>(null) +const turnstileRef = ref | null>(null) + +const useTurnstile = computed(() => settings.value['turnstile_enabled'] === true) const emailInput = ref(null) const accountInput = ref(null) @@ -124,15 +127,24 @@ function validateRam(): boolean { async function handleAdminLogin() { if (!validateAdmin()) return - if (!captcha.captchaKey || !captcha.captchaX) { - errorMsg.value = '请先完成滑块验证' - return + if (useTurnstile.value) { + if (!turnstileToken.value) { + errorMsg.value = '请先完成人机验证' + return + } + } else { + if (!captcha.captchaKey || !captcha.captchaX) { + errorMsg.value = '请先完成滑块验证' + return + } } loading.value = true errorMsg.value = '' try { - const captchaPayload = { captchaKey: captcha.captchaKey, captchaX: captcha.captchaX } + const captchaPayload = useTurnstile.value + ? { captchaKey: '', captchaX: 0, turnstileToken: turnstileToken.value } + : { captchaKey: captcha.captchaKey, captchaX: captcha.captchaX } const res = await authStore.login(adminForm.email, adminForm.password, 'admin', captchaPayload) if (res?.totp_required) { provisionalToken.value = res.provisional_token @@ -142,12 +154,16 @@ async function handleAdminLogin() { proceedAfterLogin() } catch (err) { const apiErr = extractApiError(err) - if (apiErr?.code === 10058) { + if (useTurnstile.value) { + turnstileRef.value?.reset() + errorMsg.value = apiErr?.message || '登录失败,请检查邮箱和密码' + } else if (apiErr?.code === 10058) { errorMsg.value = '滑块验证失败,请重新拖动' + captchaRef.value?.resetCaptcha() } else { errorMsg.value = apiErr?.message || '登录失败,请检查邮箱和密码' + captchaRef.value?.resetCaptcha() } - captchaRef.value?.resetCaptcha() } finally { loading.value = false } @@ -155,15 +171,24 @@ async function handleAdminLogin() { async function handleRamLogin() { if (!validateRam()) return - if (!captcha.captchaKey || !captcha.captchaX) { - errorMsg.value = '请先完成滑块验证' - return + if (useTurnstile.value) { + if (!turnstileToken.value) { + errorMsg.value = '请先完成人机验证' + return + } + } else { + if (!captcha.captchaKey || !captcha.captchaX) { + errorMsg.value = '请先完成滑块验证' + return + } } loading.value = true errorMsg.value = '' try { - const captchaPayload = { captchaKey: captcha.captchaKey, captchaX: captcha.captchaX } + const captchaPayload = useTurnstile.value + ? { captchaKey: '', captchaX: 0, turnstileToken: turnstileToken.value } + : { captchaKey: captcha.captchaKey, captchaX: captcha.captchaX } const res = await authStore.login(ramForm.account, ramForm.password, 'ram', captchaPayload) if (res?.totp_required) { provisionalToken.value = res.provisional_token @@ -173,12 +198,16 @@ async function handleRamLogin() { proceedAfterLogin() } catch (err) { const apiErr = extractApiError(err) - if (apiErr?.code === 10058) { + if (useTurnstile.value) { + turnstileRef.value?.reset() + errorMsg.value = apiErr?.message || '登录失败,请检查用户名和密码' + } else if (apiErr?.code === 10058) { errorMsg.value = '滑块验证失败,请重新拖动' + captchaRef.value?.resetCaptcha() } else { errorMsg.value = apiErr?.message || '登录失败,请检查用户名和密码' + captchaRef.value?.resetCaptcha() } - captchaRef.value?.resetCaptcha() } finally { loading.value = false } @@ -335,7 +364,8 @@ async function handleOAuthLogin(provider: string) { - + + @@ -432,7 +462,8 @@ async function handleOAuthLogin(provider: string) { - + +