Files
sub2api/frontend/src/components/auth/TotpLoginModal.vue
shaw 1245f07a2d feat(auth): 实现 TOTP 双因素认证功能
新增功能:
- 支持 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
2026-01-26 09:19:53 +08:00

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>