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
This commit is contained in:
176
frontend/src/components/auth/TotpLoginModal.vue
Normal file
176
frontend/src/components/auth/TotpLoginModal.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<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>
|
||||
154
frontend/src/components/user/profile/ProfileTotpCard.vue
Normal file
154
frontend/src/components/user/profile/ProfileTotpCard.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{{ t('profile.totp.title') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('profile.totp.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="px-6 py-6">
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
|
||||
</div>
|
||||
|
||||
<!-- Feature disabled globally -->
|
||||
<div v-else-if="status && !status.feature_enabled" class="flex items-center gap-4 py-4">
|
||||
<div class="flex-shrink-0 rounded-full bg-gray-100 p-3 dark:bg-dark-700">
|
||||
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('profile.totp.featureDisabled') }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('profile.totp.featureDisabledHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2FA Enabled -->
|
||||
<div v-else-if="status?.enabled" class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex-shrink-0 rounded-full bg-green-100 p-3 dark:bg-green-900/30">
|
||||
<svg class="h-6 w-6 text-green-600 dark:text-green-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>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
{{ t('profile.totp.enabled') }}
|
||||
</p>
|
||||
<p v-if="status.enabled_at" class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('profile.totp.enabledAt') }}: {{ formatDate(status.enabled_at) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-danger"
|
||||
@click="showDisableDialog = true"
|
||||
>
|
||||
{{ t('profile.totp.disable') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 2FA Not Enabled -->
|
||||
<div v-else class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex-shrink-0 rounded-full bg-gray-100 p-3 dark:bg-dark-700">
|
||||
<svg class="h-6 w-6 text-gray-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>
|
||||
<div>
|
||||
<p class="font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('profile.totp.notEnabled') }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('profile.totp.notEnabledHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
@click="showSetupModal = true"
|
||||
>
|
||||
{{ t('profile.totp.enable') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Setup Modal -->
|
||||
<TotpSetupModal
|
||||
v-if="showSetupModal"
|
||||
@close="showSetupModal = false"
|
||||
@success="handleSetupSuccess"
|
||||
/>
|
||||
|
||||
<!-- Disable Dialog -->
|
||||
<TotpDisableDialog
|
||||
v-if="showDisableDialog"
|
||||
@close="showDisableDialog = false"
|
||||
@success="handleDisableSuccess"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { totpAPI } from '@/api'
|
||||
import type { TotpStatus } from '@/types'
|
||||
import TotpSetupModal from './TotpSetupModal.vue'
|
||||
import TotpDisableDialog from './TotpDisableDialog.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const loading = ref(true)
|
||||
const status = ref<TotpStatus | null>(null)
|
||||
const showSetupModal = ref(false)
|
||||
const showDisableDialog = ref(false)
|
||||
|
||||
const loadStatus = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
status.value = await totpAPI.getStatus()
|
||||
} catch (error) {
|
||||
console.error('Failed to load TOTP status:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSetupSuccess = () => {
|
||||
showSetupModal.value = false
|
||||
loadStatus()
|
||||
}
|
||||
|
||||
const handleDisableSuccess = () => {
|
||||
showDisableDialog.value = false
|
||||
loadStatus()
|
||||
}
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
// Backend returns Unix timestamp in seconds, convert to milliseconds
|
||||
const date = new Date(timestamp * 1000)
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStatus()
|
||||
})
|
||||
</script>
|
||||
179
frontend/src/components/user/profile/TotpDisableDialog.vue
Normal file
179
frontend/src/components/user/profile/TotpDisableDialog.vue
Normal file
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 overflow-y-auto" @click.self="$emit('close')">
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div class="fixed inset-0 bg-black/50 transition-opacity" @click="$emit('close')"></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">
|
||||
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30">
|
||||
<svg class="h-6 w-6 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mt-4 text-center text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('profile.totp.disableTitle') }}
|
||||
</h3>
|
||||
<p class="mt-2 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('profile.totp.disableWarning') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading verification method -->
|
||||
<div v-if="methodLoading" class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
|
||||
</div>
|
||||
|
||||
<form v-else @submit.prevent="handleDisable" class="space-y-4">
|
||||
<!-- Email verification -->
|
||||
<div v-if="verificationMethod === 'email'">
|
||||
<label class="input-label">{{ t('profile.totp.emailCode') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="form.emailCode"
|
||||
type="text"
|
||||
maxlength="6"
|
||||
inputmode="numeric"
|
||||
class="input flex-1"
|
||||
:placeholder="t('profile.totp.enterEmailCode')"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary whitespace-nowrap"
|
||||
:disabled="sendingCode || codeCooldown > 0"
|
||||
@click="handleSendCode"
|
||||
>
|
||||
{{ codeCooldown > 0 ? `${codeCooldown}s` : (sendingCode ? t('common.sending') : t('profile.totp.sendCode')) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password verification -->
|
||||
<div v-else>
|
||||
<label for="password" class="input-label">
|
||||
{{ t('profile.currentPassword') }}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="input"
|
||||
:placeholder="t('profile.totp.enterPassword')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="error" class="rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button type="button" class="btn btn-secondary" @click="$emit('close')">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-danger"
|
||||
:disabled="loading || !canSubmit"
|
||||
>
|
||||
{{ loading ? t('common.processing') : t('profile.totp.confirmDisable') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { totpAPI } from '@/api'
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
success: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const methodLoading = ref(true)
|
||||
const verificationMethod = ref<'email' | 'password'>('password')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const sendingCode = ref(false)
|
||||
const codeCooldown = ref(0)
|
||||
const form = ref({
|
||||
emailCode: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
if (verificationMethod.value === 'email') {
|
||||
return form.value.emailCode.length === 6
|
||||
}
|
||||
return form.value.password.length > 0
|
||||
})
|
||||
|
||||
const loadVerificationMethod = async () => {
|
||||
methodLoading.value = true
|
||||
try {
|
||||
const method = await totpAPI.getVerificationMethod()
|
||||
verificationMethod.value = method.method
|
||||
} catch (err: any) {
|
||||
appStore.showError(err.response?.data?.message || t('common.error'))
|
||||
emit('close')
|
||||
} finally {
|
||||
methodLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendCode = async () => {
|
||||
sendingCode.value = true
|
||||
try {
|
||||
await totpAPI.sendVerifyCode()
|
||||
appStore.showSuccess(t('profile.totp.codeSent'))
|
||||
// Start cooldown
|
||||
codeCooldown.value = 60
|
||||
const timer = setInterval(() => {
|
||||
codeCooldown.value--
|
||||
if (codeCooldown.value <= 0) {
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, 1000)
|
||||
} catch (err: any) {
|
||||
appStore.showError(err.response?.data?.message || t('profile.totp.sendCodeFailed'))
|
||||
} finally {
|
||||
sendingCode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisable = async () => {
|
||||
if (!canSubmit.value) return
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const request = verificationMethod.value === 'email'
|
||||
? { email_code: form.value.emailCode }
|
||||
: { password: form.value.password }
|
||||
|
||||
await totpAPI.disable(request)
|
||||
appStore.showSuccess(t('profile.totp.disableSuccess'))
|
||||
emit('success')
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.message || t('profile.totp.disableFailed')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadVerificationMethod()
|
||||
})
|
||||
</script>
|
||||
400
frontend/src/components/user/profile/TotpSetupModal.vue
Normal file
400
frontend/src/components/user/profile/TotpSetupModal.vue
Normal file
@@ -0,0 +1,400 @@
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 overflow-y-auto" @click.self="$emit('close')">
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div class="fixed inset-0 bg-black/50 transition-opacity" @click="$emit('close')"></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">
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('profile.totp.setupTitle') }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ stepDescription }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Step 0: Identity Verification -->
|
||||
<div v-if="step === 0" class="space-y-6">
|
||||
<!-- Loading verification method -->
|
||||
<div v-if="methodLoading" class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Email verification -->
|
||||
<div v-if="verificationMethod === 'email'" class="space-y-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('profile.totp.emailCode') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="verifyForm.emailCode"
|
||||
type="text"
|
||||
maxlength="6"
|
||||
inputmode="numeric"
|
||||
class="input flex-1"
|
||||
:placeholder="t('profile.totp.enterEmailCode')"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary whitespace-nowrap"
|
||||
:disabled="sendingCode || codeCooldown > 0"
|
||||
@click="handleSendCode"
|
||||
>
|
||||
{{ codeCooldown > 0 ? `${codeCooldown}s` : (sendingCode ? t('common.sending') : t('profile.totp.sendCode')) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password verification -->
|
||||
<div v-else class="space-y-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('profile.currentPassword') }}</label>
|
||||
<input
|
||||
v-model="verifyForm.password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="input"
|
||||
:placeholder="t('profile.totp.enterPassword')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="verifyError" class="rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400">
|
||||
{{ verifyError }}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button type="button" class="btn btn-secondary" @click="$emit('close')">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:disabled="!canProceedFromVerify || setupLoading"
|
||||
@click="handleVerifyAndSetup"
|
||||
>
|
||||
{{ setupLoading ? t('common.loading') : t('common.next') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: Show QR Code -->
|
||||
<div v-if="step === 1" class="space-y-6">
|
||||
<!-- QR Code and Secret -->
|
||||
<template v-if="setupData">
|
||||
<div class="flex justify-center">
|
||||
<div class="rounded-lg border border-gray-200 p-4 bg-white dark:border-dark-600 dark:bg-white">
|
||||
<img :src="qrCodeDataUrl" alt="QR Code" class="h-48 w-48" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||||
{{ t('profile.totp.manualEntry') }}
|
||||
</p>
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<code class="rounded bg-gray-100 px-3 py-2 font-mono text-sm dark:bg-dark-700">
|
||||
{{ setupData.secret }}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-1.5 text-gray-500 hover:bg-gray-100 dark:hover:bg-dark-700"
|
||||
@click="copySecret"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button type="button" class="btn btn-secondary" @click="$emit('close')">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:disabled="!setupData"
|
||||
@click="step = 2"
|
||||
>
|
||||
{{ t('common.next') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Verify Code -->
|
||||
<div v-if="step === 2" class="space-y-6">
|
||||
<form @submit.prevent="handleVerify">
|
||||
<div class="mb-6">
|
||||
<label class="input-label text-center block mb-3">
|
||||
{{ t('profile.totp.enterCode') }}
|
||||
</label>
|
||||
<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"
|
||||
@input="handleCodeInput($event, index)"
|
||||
@keydown="handleKeydown($event, index)"
|
||||
@paste="handlePaste"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" class="btn btn-secondary" @click="step = 1">
|
||||
{{ t('common.back') }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="verifying || code.join('').length !== 6"
|
||||
>
|
||||
{{ verifying ? t('common.verifying') : t('profile.totp.verify') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick, watch, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { totpAPI } from '@/api'
|
||||
import type { TotpSetupResponse } from '@/types'
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
success: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// Step: 0 = verify identity, 1 = QR code, 2 = verify TOTP code
|
||||
const step = ref(0)
|
||||
const methodLoading = ref(true)
|
||||
const verificationMethod = ref<'email' | 'password'>('password')
|
||||
const verifyForm = ref({ emailCode: '', password: '' })
|
||||
const verifyError = ref('')
|
||||
const sendingCode = ref(false)
|
||||
const codeCooldown = ref(0)
|
||||
|
||||
const setupLoading = ref(false)
|
||||
const setupData = ref<TotpSetupResponse | null>(null)
|
||||
const verifying = ref(false)
|
||||
const error = ref('')
|
||||
const code = ref<string[]>(['', '', '', '', '', ''])
|
||||
const inputRefs = ref<(HTMLInputElement | null)[]>([])
|
||||
const qrCodeDataUrl = ref('')
|
||||
|
||||
const stepDescription = computed(() => {
|
||||
switch (step.value) {
|
||||
case 0:
|
||||
return verificationMethod.value === 'email'
|
||||
? t('profile.totp.verifyEmailFirst')
|
||||
: t('profile.totp.verifyPasswordFirst')
|
||||
case 1:
|
||||
return t('profile.totp.setupStep1')
|
||||
case 2:
|
||||
return t('profile.totp.setupStep2')
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
const canProceedFromVerify = computed(() => {
|
||||
if (verificationMethod.value === 'email') {
|
||||
return verifyForm.value.emailCode.length === 6
|
||||
}
|
||||
return verifyForm.value.password.length > 0
|
||||
})
|
||||
|
||||
// Generate QR code as base64 when setupData changes
|
||||
watch(
|
||||
() => setupData.value?.qr_code_url,
|
||||
async (url) => {
|
||||
if (url) {
|
||||
try {
|
||||
qrCodeDataUrl.value = await QRCode.toDataURL(url, {
|
||||
width: 200,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#ffffff'
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to generate QR code:', err)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
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()
|
||||
})
|
||||
}
|
||||
|
||||
const copySecret = async () => {
|
||||
if (setupData.value) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(setupData.value.secret)
|
||||
appStore.showSuccess(t('common.copied'))
|
||||
} catch {
|
||||
appStore.showError(t('common.copyFailed'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loadVerificationMethod = async () => {
|
||||
methodLoading.value = true
|
||||
try {
|
||||
const method = await totpAPI.getVerificationMethod()
|
||||
verificationMethod.value = method.method
|
||||
} catch (err: any) {
|
||||
appStore.showError(err.response?.data?.message || t('common.error'))
|
||||
emit('close')
|
||||
} finally {
|
||||
methodLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendCode = async () => {
|
||||
sendingCode.value = true
|
||||
try {
|
||||
await totpAPI.sendVerifyCode()
|
||||
appStore.showSuccess(t('profile.totp.codeSent'))
|
||||
// Start cooldown
|
||||
codeCooldown.value = 60
|
||||
const timer = setInterval(() => {
|
||||
codeCooldown.value--
|
||||
if (codeCooldown.value <= 0) {
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, 1000)
|
||||
} catch (err: any) {
|
||||
appStore.showError(err.response?.data?.message || t('profile.totp.sendCodeFailed'))
|
||||
} finally {
|
||||
sendingCode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerifyAndSetup = async () => {
|
||||
setupLoading.value = true
|
||||
verifyError.value = ''
|
||||
|
||||
try {
|
||||
const request = verificationMethod.value === 'email'
|
||||
? { email_code: verifyForm.value.emailCode }
|
||||
: { password: verifyForm.value.password }
|
||||
|
||||
setupData.value = await totpAPI.initiateSetup(request)
|
||||
step.value = 1
|
||||
} catch (err: any) {
|
||||
verifyError.value = err.response?.data?.message || t('profile.totp.setupFailed')
|
||||
} finally {
|
||||
setupLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerify = async () => {
|
||||
const totpCode = code.value.join('')
|
||||
if (totpCode.length !== 6 || !setupData.value) return
|
||||
|
||||
verifying.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
await totpAPI.enable({
|
||||
totp_code: totpCode,
|
||||
setup_token: setupData.value.setup_token
|
||||
})
|
||||
appStore.showSuccess(t('profile.totp.enableSuccess'))
|
||||
emit('success')
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.message || t('profile.totp.verifyFailed')
|
||||
code.value = ['', '', '', '', '', '']
|
||||
nextTick(() => {
|
||||
inputRefs.value[0]?.focus()
|
||||
})
|
||||
} finally {
|
||||
verifying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadVerificationMethod()
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user