新增功能: - 支持 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
180 lines
5.8 KiB
Vue
180 lines
5.8 KiB
Vue
<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>
|