新增功能: - 支持 Google Authenticator 等应用进行 TOTP 二次验证 - 用户可在个人设置中启用/禁用 2FA - 登录时支持 TOTP 验证流程 - 管理后台可全局开关 TOTP 功能 安全增强: - TOTP 密钥使用 AES-256-GCM 加密存储 - 添加 TOTP_ENCRYPTION_KEY 配置项,必须手动配置才能启用功能 - 防止服务重启导致加密密钥变更使用户无法登录 - 验证失败次数限制,防止暴力破解 配置说明: - Docker 部署:在 .env 中设置 TOTP_ENCRYPTION_KEY - 非 Docker 部署:在 config.yaml 中设置 totp.encryption_key - 生成密钥命令:openssl rand -hex 32
177 lines
5.5 KiB
Vue
177 lines
5.5 KiB
Vue
<template>
|
|
<div class="fixed inset-0 z-50 overflow-y-auto">
|
|
<div class="flex min-h-full items-center justify-center p-4">
|
|
<div class="fixed inset-0 bg-black/50 transition-opacity"></div>
|
|
|
|
<div class="relative w-full max-w-md transform rounded-xl bg-white p-6 shadow-xl transition-all dark:bg-dark-800">
|
|
<!-- Header -->
|
|
<div class="mb-6 text-center">
|
|
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30">
|
|
<svg class="h-6 w-6 text-primary-600 dark:text-primary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
|
</svg>
|
|
</div>
|
|
<h3 class="mt-4 text-xl font-semibold text-gray-900 dark:text-white">
|
|
{{ t('profile.totp.loginTitle') }}
|
|
</h3>
|
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
|
{{ t('profile.totp.loginHint') }}
|
|
</p>
|
|
<p v-if="userEmailMasked" class="mt-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
{{ userEmailMasked }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Code Input -->
|
|
<div class="mb-6">
|
|
<div class="flex justify-center gap-2">
|
|
<input
|
|
v-for="(_, index) in 6"
|
|
:key="index"
|
|
:ref="(el) => setInputRef(el, index)"
|
|
type="text"
|
|
maxlength="1"
|
|
inputmode="numeric"
|
|
pattern="[0-9]"
|
|
class="h-12 w-10 rounded-lg border border-gray-300 text-center text-lg font-semibold focus:border-primary-500 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-700"
|
|
:disabled="verifying"
|
|
@input="handleCodeInput($event, index)"
|
|
@keydown="handleKeydown($event, index)"
|
|
@paste="handlePaste"
|
|
/>
|
|
</div>
|
|
<!-- Loading indicator -->
|
|
<div v-if="verifying" class="mt-3 flex items-center justify-center gap-2 text-sm text-gray-500">
|
|
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary-500"></div>
|
|
{{ t('common.verifying') }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Error -->
|
|
<div v-if="error" class="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400">
|
|
{{ error }}
|
|
</div>
|
|
|
|
<!-- Cancel button only -->
|
|
<button
|
|
type="button"
|
|
class="btn btn-secondary w-full"
|
|
:disabled="verifying"
|
|
@click="$emit('cancel')"
|
|
>
|
|
{{ t('common.cancel') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, watch, nextTick, onMounted } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
defineProps<{
|
|
tempToken: string
|
|
userEmailMasked?: string
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
verify: [code: string]
|
|
cancel: []
|
|
}>()
|
|
|
|
const { t } = useI18n()
|
|
|
|
const verifying = ref(false)
|
|
const error = ref('')
|
|
const code = ref<string[]>(['', '', '', '', '', ''])
|
|
const inputRefs = ref<(HTMLInputElement | null)[]>([])
|
|
|
|
// Watch for code changes and auto-submit when 6 digits are entered
|
|
watch(
|
|
() => code.value.join(''),
|
|
(newCode) => {
|
|
if (newCode.length === 6 && !verifying.value) {
|
|
emit('verify', newCode)
|
|
}
|
|
}
|
|
)
|
|
|
|
defineExpose({
|
|
setVerifying: (value: boolean) => { verifying.value = value },
|
|
setError: (message: string) => {
|
|
error.value = message
|
|
code.value = ['', '', '', '', '', '']
|
|
// Clear input DOM values
|
|
inputRefs.value.forEach(input => {
|
|
if (input) input.value = ''
|
|
})
|
|
nextTick(() => {
|
|
inputRefs.value[0]?.focus()
|
|
})
|
|
}
|
|
})
|
|
|
|
const setInputRef = (el: any, index: number) => {
|
|
inputRefs.value[index] = el as HTMLInputElement | null
|
|
}
|
|
|
|
const handleCodeInput = (event: Event, index: number) => {
|
|
const input = event.target as HTMLInputElement
|
|
const value = input.value.replace(/[^0-9]/g, '')
|
|
code.value[index] = value
|
|
|
|
if (value && index < 5) {
|
|
nextTick(() => {
|
|
inputRefs.value[index + 1]?.focus()
|
|
})
|
|
}
|
|
}
|
|
|
|
const handleKeydown = (event: KeyboardEvent, index: number) => {
|
|
if (event.key === 'Backspace') {
|
|
const input = event.target as HTMLInputElement
|
|
// If current cell is empty and not the first, move to previous cell
|
|
if (!input.value && index > 0) {
|
|
event.preventDefault()
|
|
inputRefs.value[index - 1]?.focus()
|
|
}
|
|
// Otherwise, let the browser handle the backspace naturally
|
|
// The input event will sync code.value via handleCodeInput
|
|
}
|
|
}
|
|
|
|
const handlePaste = (event: ClipboardEvent) => {
|
|
event.preventDefault()
|
|
const pastedData = event.clipboardData?.getData('text') || ''
|
|
const digits = pastedData.replace(/[^0-9]/g, '').slice(0, 6).split('')
|
|
|
|
// Update both the ref and the input elements
|
|
digits.forEach((digit, index) => {
|
|
code.value[index] = digit
|
|
if (inputRefs.value[index]) {
|
|
inputRefs.value[index]!.value = digit
|
|
}
|
|
})
|
|
|
|
// Clear remaining inputs if pasted less than 6 digits
|
|
for (let i = digits.length; i < 6; i++) {
|
|
code.value[i] = ''
|
|
if (inputRefs.value[i]) {
|
|
inputRefs.value[i]!.value = ''
|
|
}
|
|
}
|
|
|
|
const focusIndex = Math.min(digits.length, 5)
|
|
nextTick(() => {
|
|
inputRefs.value[focusIndex]?.focus()
|
|
})
|
|
}
|
|
|
|
onMounted(() => {
|
|
nextTick(() => {
|
|
inputRefs.value[0]?.focus()
|
|
})
|
|
})
|
|
</script>
|