mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-08 16:56:23 +00:00
人机验证增加阿里云验证码 2.0
沿用腾讯天御验证码引入的多服务商模型:aliyun_captcha_enabled 作为独立 开关,与 Cloudflare Turnstile、腾讯天御三方互斥(保存校验 + 运行时 CAPTCHA_PROVIDER_CONFLICT)。后台「安全与认证」合并为单张人机验证卡片: 总开关 + 服务商单选(Turnstile / 腾讯天御 / 阿里云),选中即启用该家并 关闭其它,落库仍是三个独立开关键,由前端映射保证互斥。 阿里云侧同时支持 aliyun 中国站与国际站(alibabacloud.com):两站前端脚本、 region 取值与服务端 API 完全一致,仅账号与 AccessKey 相互独立,因此由 「服务地域」决定线路即可——中国内地走 captcha.cn-shanghai.aliyuncs.com, 非中国内地(新加坡)走 captcha.ap-southeast-1.aliyuncs.com,AccessKey 取自持有该实例的账号,无需在配置中区分站点。 - AliyunCaptchaService 对称 TencentCaptchaService:服务端校验走官方 SDK VerifyIntelligentCaptcha,调用异常按 fail-closed 拦截,与 Turnstile 网络错误行为对称;保存设置时真实探测 AK/SK 有效性 - 保护面对齐腾讯扩展入口:VerifyTencentCaptchaIfEnabled 通用化为 VerifyActionCaptchaIfEnabled,OAuth 登录启动、passkey 登录在阿里云 启用时同样拦截;Turnstile 维持既有覆盖不扩大 - 前端 AliyunCaptchaWidget 为表单内预验证按钮(popup 模式),同时暴露 verify() 供 OAuth 启动、passkey 等动作入口程序化弹窗;未预验证直接 提交时弹窗兜底。SDK 按钮绑定异步完成,弹窗未出现前按 tick 重试触发, 并轮询弹窗可见性识别用户关闭 - captchaVerifyParam 复用 turnstile_token 请求字段提交;公开设置下发 aliyun_captcha_enabled / scene_id / prefix / region - CSP 放行验证码 CDN:script-src/style-src 加 *.alicdn.com
This commit is contained in:
1 parent
d431c57f2e
commit
26e0a89323
57 files changed
+2445
-302
No files matched your search
@@ -24,6 +24,10 @@
|
||||
:turnstile-site-key="turnstileSiteKey"
|
||||
:tencent-enabled="tencentCaptchaEnabled"
|
||||
:tencent-app-id="tencentCaptchaAppId"
|
||||
:aliyun-enabled="aliyunCaptchaEnabled"
|
||||
:aliyun-scene-id="aliyunCaptchaSceneId"
|
||||
:aliyun-prefix="aliyunCaptchaPrefix"
|
||||
:aliyun-region="aliyunCaptchaRegion"
|
||||
@verify="onTurnstileVerify"
|
||||
@expire="onTurnstileExpire"
|
||||
@error="onTurnstileError"
|
||||
@@ -137,13 +141,28 @@ const turnstileEnabled = ref(false)
|
||||
const turnstileSiteKey = ref('')
|
||||
const tencentCaptchaEnabled = ref(false)
|
||||
const tencentCaptchaAppId = ref('')
|
||||
const aliyunCaptchaEnabled = ref(false)
|
||||
const aliyunCaptchaSceneId = ref('')
|
||||
const aliyunCaptchaPrefix = ref('')
|
||||
const aliyunCaptchaRegion = ref('cn')
|
||||
const turnstileToken = ref('')
|
||||
const tencentCaptchaRandstr = ref('')
|
||||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||||
const aliyunCaptchaReady = computed(
|
||||
() =>
|
||||
aliyunCaptchaEnabled.value &&
|
||||
Boolean(aliyunCaptchaSceneId.value) &&
|
||||
Boolean(aliyunCaptchaPrefix.value)
|
||||
)
|
||||
// 动作触发式验证码(腾讯/阿里云):发送验证码、提交时弹窗验证
|
||||
const actionCaptchaEnabled = computed(
|
||||
() =>
|
||||
(tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value)) ||
|
||||
aliyunCaptchaReady.value
|
||||
)
|
||||
const captchaEnabled = computed(
|
||||
() =>
|
||||
(turnstileEnabled.value && Boolean(turnstileSiteKey.value)) ||
|
||||
(tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value))
|
||||
(turnstileEnabled.value && Boolean(turnstileSiteKey.value)) || actionCaptchaEnabled.value
|
||||
)
|
||||
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
@@ -229,13 +248,13 @@ function onTurnstileError() {
|
||||
sendCodeError.value = t('auth.turnstileFailed')
|
||||
}
|
||||
|
||||
async function acquireTencentProof(): Promise<boolean> {
|
||||
if (!tencentCaptchaEnabled.value) return true
|
||||
async function acquireActionProof(): Promise<boolean> {
|
||||
if (!actionCaptchaEnabled.value) return true
|
||||
|
||||
const proof = await turnstileRef.value?.verifyTencent()
|
||||
const proof = await turnstileRef.value?.verifyAction()
|
||||
if (!proof) return false
|
||||
|
||||
turnstileToken.value = proof.ticket
|
||||
turnstileToken.value = proof.token
|
||||
tencentCaptchaRandstr.value = proof.randstr
|
||||
return true
|
||||
}
|
||||
@@ -251,7 +270,7 @@ async function handleSendCode() {
|
||||
return
|
||||
}
|
||||
|
||||
if (!(await acquireTencentProof())) {
|
||||
if (!(await acquireActionProof())) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -262,7 +281,8 @@ async function handleSendCode() {
|
||||
try {
|
||||
const response = await sendPendingOAuthVerifyCode({
|
||||
email: trimmedEmail,
|
||||
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined,
|
||||
turnstile_token:
|
||||
turnstileEnabled.value || aliyunCaptchaEnabled.value ? turnstileToken.value : undefined,
|
||||
tencent_captcha_ticket: tencentCaptchaEnabled.value ? turnstileToken.value : undefined,
|
||||
tencent_captcha_randstr: tencentCaptchaEnabled.value ? tencentCaptchaRandstr.value : undefined
|
||||
})
|
||||
@@ -292,7 +312,7 @@ async function handleSubmit() {
|
||||
return
|
||||
}
|
||||
|
||||
if (!(await acquireTencentProof())) {
|
||||
if (!(await acquireActionProof())) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -300,7 +320,9 @@ async function handleSubmit() {
|
||||
email: trimmedEmail,
|
||||
password: password.value,
|
||||
verifyCode: emailVerifyEnabled.value ? verifyCode.value.trim() : '',
|
||||
...(turnstileEnabled.value && turnstileToken.value ? { turnstileToken: turnstileToken.value } : {}),
|
||||
...((turnstileEnabled.value || aliyunCaptchaEnabled.value) && turnstileToken.value
|
||||
? { turnstileToken: turnstileToken.value }
|
||||
: {}),
|
||||
...(tencentCaptchaEnabled.value && turnstileToken.value
|
||||
? {
|
||||
tencentCaptchaTicket: turnstileToken.value,
|
||||
@@ -310,7 +332,7 @@ async function handleSubmit() {
|
||||
invitationCode: invitationCode.value.trim() || undefined
|
||||
})
|
||||
|
||||
if (tencentCaptchaEnabled.value) {
|
||||
if (actionCaptchaEnabled.value) {
|
||||
resetTurnstile()
|
||||
}
|
||||
}
|
||||
@@ -328,6 +350,10 @@ onMounted(async () => {
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||
tencentCaptchaEnabled.value = settings.tencent_captcha_enabled === true
|
||||
tencentCaptchaAppId.value = settings.tencent_captcha_app_id || ''
|
||||
aliyunCaptchaEnabled.value = settings.aliyun_captcha_enabled === true
|
||||
aliyunCaptchaSceneId.value = settings.aliyun_captcha_scene_id || ''
|
||||
aliyunCaptchaPrefix.value = settings.aliyun_captcha_prefix || ''
|
||||
aliyunCaptchaRegion.value = settings.aliyun_captcha_region || 'cn'
|
||||
} catch {
|
||||
invitationCodeEnabled.value = false
|
||||
emailVerifyEnabled.value = true
|
||||
@@ -335,6 +361,10 @@ onMounted(async () => {
|
||||
turnstileSiteKey.value = ''
|
||||
tencentCaptchaEnabled.value = false
|
||||
tencentCaptchaAppId.value = ''
|
||||
aliyunCaptchaEnabled.value = false
|
||||
aliyunCaptchaSceneId.value = ''
|
||||
aliyunCaptchaPrefix.value = ''
|
||||
aliyunCaptchaRegion.value = 'cn'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ const sendPendingOAuthVerifyCode = vi.fn()
|
||||
const getPublicSettings = vi.fn()
|
||||
const showError = vi.fn()
|
||||
const turnstileReset = vi.fn()
|
||||
const verifyTencent = vi.fn()
|
||||
const verifyAction = vi.fn()
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
@@ -44,7 +44,7 @@ describe('PendingOAuthCreateAccountForm', () => {
|
||||
getPublicSettings.mockReset()
|
||||
showError.mockReset()
|
||||
turnstileReset.mockReset()
|
||||
verifyTencent.mockReset()
|
||||
verifyAction.mockReset()
|
||||
getPublicSettings.mockResolvedValue({
|
||||
turnstile_enabled: false,
|
||||
turnstile_site_key: ''
|
||||
@@ -60,12 +60,12 @@ describe('PendingOAuthCreateAccountForm', () => {
|
||||
tencent_captcha_app_id: 'tencent-app-id'
|
||||
})
|
||||
sendPendingOAuthVerifyCode.mockResolvedValue({ countdown: 0 })
|
||||
verifyTencent
|
||||
.mockResolvedValueOnce({ ticket: 'ticket-1', randstr: '@rand-1' })
|
||||
.mockResolvedValueOnce({ ticket: 'ticket-2', randstr: '@rand-2' })
|
||||
verifyAction
|
||||
.mockResolvedValueOnce({ token: 'ticket-1', randstr: '@rand-1' })
|
||||
.mockResolvedValueOnce({ token: 'ticket-2', randstr: '@rand-2' })
|
||||
const CaptchaChallengeStub = defineComponent({
|
||||
setup(_, { expose }) {
|
||||
expose({ verifyTencent, reset: turnstileReset })
|
||||
expose({ verifyAction, reset: turnstileReset })
|
||||
return () => h('div')
|
||||
}
|
||||
})
|
||||
@@ -89,7 +89,7 @@ describe('PendingOAuthCreateAccountForm', () => {
|
||||
await wrapper.get('[data-testid="oidc-create-account-submit"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(verifyTencent).toHaveBeenCalledTimes(2)
|
||||
expect(verifyAction).toHaveBeenCalledTimes(2)
|
||||
expect(sendPendingOAuthVerifyCode).toHaveBeenCalledWith({
|
||||
email: 'user@example.com',
|
||||
tencent_captcha_ticket: 'ticket-1',
|
||||
|
||||
Reference in new issue
Block a user