验证码优化

This commit is contained in:
biss committed 2026-09-12 07:57:27 +08:00
1 parent f489a3553c
commit 4a3bb84aa4
17 files changed
+156 -430

No files matched your search

-13
View File
@@ -1,13 +0,0 @@
import FingerprintJS from '@fingerprintjs/fingerprintjs'
const fingerprint = FingerprintJS.load()
/** A pseudonymous, client-generated identifier used only to bind short-lived login challenges. */
export async function getLoginDeviceId(): Promise<string> {
try {
return (await fingerprint).get().then((result) => result.visitorId)
} catch {
// Privacy extensions may block fingerprinting. The captcha remains usable with IP/session binding.
return ''
}
}
+22 -92
View File
@@ -1,106 +1,43 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { onMounted, ref, watch } from 'vue'
import http from '../api/http'
import { getLoginDeviceId } from '../auth/deviceFingerprint'
type CaptchaKind = 'Text' | 'Slider' | 'Click'
interface Point { x: number; y: number }
interface Challenge {
id: string
kind: CaptchaKind
expiresInSeconds: number
imageSvg: string
prompt: string
interface CaptchaImage {
captchaId: string
imageData: string
}
const emit = defineEmits<{ verified: [ticket: string, deviceId: string]; invalidated: [] }>()
const challenge = ref<Challenge | null>(null)
const emit = defineEmits<{ change: [captchaId: string, captchaCode: string] }>()
const captcha = ref<CaptchaImage | null>(null)
const code = ref('')
const loading = ref(false)
const verifying = ref(false)
const text = ref('')
const sliderX = ref(0)
const clicks = reactive<Point[]>([])
const image = ref<HTMLElement | null>(null)
const isVerified = ref(false)
const canVerify = computed(() =>
challenge.value?.kind === 'Text' ? text.value.trim().length > 0
: challenge.value?.kind === 'Slider' ? sliderX.value > 0
: clicks.length === 2)
async function refresh() {
loading.value = true
isVerified.value = false
text.value = ''
sliderX.value = 0
clicks.splice(0)
emit('invalidated')
code.value = ''
try {
const deviceId = await getLoginDeviceId()
const { data } = await http.post('/auth/login/captcha', { deviceId }, { skipAuthSession: true })
challenge.value = data
const { data } = await http.post('/auth/login/captcha', {}, { skipAuthSession: true })
captcha.value = data
} finally {
loading.value = false
}
}
function onClick(event: MouseEvent) {
if (challenge.value?.kind !== 'Click' || isVerified.value || clicks.length >= 2 || !image.value) return
const bounds = image.value.getBoundingClientRect()
clicks.push({
x: Math.round((event.clientX - bounds.left) * 240 / bounds.width),
y: Math.round((event.clientY - bounds.top) * 132 / bounds.height),
})
}
async function verify() {
if (!challenge.value || !canVerify.value) return
verifying.value = true
try {
const deviceId = await getLoginDeviceId()
const { data } = await http.post('/auth/login/captcha/verify', {
challengeId: challenge.value.id,
deviceId,
text: text.value,
sliderX: sliderX.value,
clicks,
}, { skipAuthSession: true })
isVerified.value = true
emit('verified', String(data.captchaTicket), deviceId)
ElMessage.success('安全验证通过')
} catch {
ElMessage.error('验证未通过,请按提示重试。')
await refresh()
} finally {
verifying.value = false
}
}
watch([captcha, code], () => emit('change', captcha.value?.captchaId ?? '', code.value))
onMounted(refresh)
defineExpose({ refresh })
</script>
<template>
<section class="login-captcha" aria-label="安全验证">
<div class="captcha-heading"><span>安全验证</span><button type="button" :disabled="loading" @click="refresh">换一题</button></div>
<p v-if="challenge" class="captcha-prompt">{{ challenge.prompt }}</p>
<div v-loading="loading" class="captcha-stage">
<template v-if="challenge">
<div ref="image" class="captcha-image" :class="{ clickable: challenge.kind === 'Click' }" @click="onClick">
<img :src="challenge.imageSvg" alt="验证码图像" draggable="false" />
<i v-for="(point, index) in clicks" :key="`${point.x}-${point.y}`" :style="{ left: `${point.x / 2.4}%`, top: `${point.y / 1.32}%` }">{{ index + 1 }}</i>
</div>
<el-input v-if="challenge.kind === 'Text'" v-model="text" maxlength="5" autocomplete="off" placeholder="输入图中字符" />
<div v-else-if="challenge.kind === 'Slider'" class="slider-control">
<span class="slider-piece" :style="{ left: `${sliderX}px` }">◆</span>
<input v-model.number="sliderX" type="range" min="0" max="220" aria-label="拼图滑块" />
</div>
<small v-else-if="challenge.kind === 'Click'">已选择 {{ clicks.length }}/2 个字符</small>
</template>
<section class="login-captcha" aria-label="验证码">
<div class="captcha-heading">
<span>验证码</span>
<button type="button" :disabled="loading" @click="refresh">换一张</button>
</div>
<div v-loading="loading" class="captcha-input">
<img v-if="captcha" :src="captcha.imageData" alt="验证码图片,点击可更换" @click="refresh" />
<el-input v-model="code" maxlength="8" autocomplete="off" placeholder="输入图中字符" />
</div>
<el-button type="default" :disabled="!canVerify || isVerified" :loading="verifying" @click="verify">
{{ isVerified ? '已验证' : '完成验证' }}
</el-button>
</section>
</template>
@@ -108,14 +45,7 @@ defineExpose({ refresh })
.login-captcha { display: grid; gap: 8px; }
.captcha-heading { display: flex; justify-content: space-between; align-items: center; font-size: 14px; font-weight: 650; color: #233b54; }
.captcha-heading button { border: 0; background: transparent; color: #467cae; cursor: pointer; font: inherit; }
.captcha-prompt { margin: 0; color: #5c7084; font-size: 13px; }
.captcha-stage { min-height: 132px; display: grid; gap: 8px; }
.captcha-image { position: relative; width: min(100%, 280px); line-height: 0; overflow: hidden; border-radius: 9px; }
.captcha-image.clickable { cursor: crosshair; }
.captcha-image img { width: 100%; user-select: none; }
.captcha-image i { position: absolute; transform: translate(-50%, -50%); display: grid; place-items: center; width: 22px; height: 22px; border-radius: 50%; background: #1769aa; color: white; font-size: 12px; font-style: normal; line-height: 1; }
.slider-control { position: relative; width: min(100%, 280px); height: 35px; display: grid; place-items: center; background: #edf3f8; border-radius: 7px; }
.slider-control input { width: calc(100% - 18px); accent-color: #347ab1; }
.slider-piece { position: absolute; top: -29px; color: white; text-shadow: 0 1px 3px #1d3a55; pointer-events: none; }
small { color: #63768b; }
.captcha-input { display: grid; grid-template-columns: 150px minmax(0, 1fr); gap: 10px; align-items: center; }
.captcha-input img { width: 150px; height: 50px; object-fit: contain; border: 1px solid #d7e2ed; border-radius: 6px; cursor: pointer; background: #f7fafc; }
@media (max-width: 420px) { .captcha-input { grid-template-columns: 130px minmax(0, 1fr); } .captcha-input img { width: 130px; } }
</style>
+4 -4
View File
@@ -31,14 +31,14 @@ export const useAuthStore = defineStore('auth', () => {
async function login(
userName: string,
password: string,
captchaTicket: string,
deviceId: string,
captchaId: string,
captchaCode: string,
): Promise<{ twoFactorTicket?: string }> {
const { data } = await http.post('/auth/login', {
userName,
password,
captchaTicket,
deviceId,
captchaId,
captchaCode,
isNativeApp: isNativeApp(),
})
if (data.requiresTotp) return { twoFactorTicket: String(data.twoFactorTicket) }
Loaded 3 of 17 files, more files were not shown because too many files have changed in this diff. Show more