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:
shaw
2026-01-26 08:45:43 +08:00
parent 74e05b83ea
commit 1245f07a2d
60 changed files with 4140 additions and 350 deletions

View File

@@ -163,6 +163,16 @@
</p>
</template>
</AuthLayout>
<!-- 2FA Modal -->
<TotpLoginModal
v-if="show2FAModal"
ref="totpModalRef"
:temp-token="totpTempToken"
:user-email-masked="totpUserEmailMasked"
@verify="handle2FAVerify"
@cancel="handle2FACancel"
/>
</template>
<script setup lang="ts">
@@ -171,10 +181,12 @@ import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout'
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
import TotpLoginModal from '@/components/auth/TotpLoginModal.vue'
import Icon from '@/components/icons/Icon.vue'
import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAuthStore, useAppStore } from '@/stores'
import { getPublicSettings } from '@/api/auth'
import { getPublicSettings, isTotp2FARequired } from '@/api/auth'
import type { TotpLoginResponse } from '@/types'
const { t } = useI18n()
@@ -200,6 +212,12 @@ const passwordResetEnabled = ref<boolean>(false)
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
const turnstileToken = ref<string>('')
// 2FA state
const show2FAModal = ref<boolean>(false)
const totpTempToken = ref<string>('')
const totpUserEmailMasked = ref<string>('')
const totpModalRef = ref<InstanceType<typeof TotpLoginModal> | null>(null)
const formData = reactive({
email: '',
password: ''
@@ -302,12 +320,22 @@ async function handleLogin(): Promise<void> {
try {
// Call auth store login
await authStore.login({
const response = await authStore.login({
email: formData.email,
password: formData.password,
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined
})
// Check if 2FA is required
if (isTotp2FARequired(response)) {
const totpResponse = response as TotpLoginResponse
totpTempToken.value = totpResponse.temp_token || ''
totpUserEmailMasked.value = totpResponse.user_email_masked || ''
show2FAModal.value = true
isLoading.value = false
return
}
// Show success toast
appStore.showSuccess(t('auth.loginSuccess'))
@@ -338,6 +366,40 @@ async function handleLogin(): Promise<void> {
isLoading.value = false
}
}
// ==================== 2FA Handlers ====================
async function handle2FAVerify(code: string): Promise<void> {
if (totpModalRef.value) {
totpModalRef.value.setVerifying(true)
}
try {
await authStore.login2FA(totpTempToken.value, code)
// Close modal and show success
show2FAModal.value = false
appStore.showSuccess(t('auth.loginSuccess'))
// Redirect to dashboard or intended route
const redirectTo = (router.currentRoute.value.query.redirect as string) || '/dashboard'
await router.push(redirectTo)
} catch (error: unknown) {
const err = error as { message?: string; response?: { data?: { message?: string } } }
const message = err.response?.data?.message || err.message || t('profile.totp.loginFailed')
if (totpModalRef.value) {
totpModalRef.value.setError(message)
totpModalRef.value.setVerifying(false)
}
}
}
function handle2FACancel(): void {
show2FAModal.value = false
totpTempToken.value = ''
totpUserEmailMasked.value = ''
}
</script>
<style scoped>