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:
@@ -354,6 +354,31 @@
|
||||
</div>
|
||||
<Toggle v-model="form.password_reset_enabled" />
|
||||
</div>
|
||||
|
||||
<!-- TOTP 2FA -->
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
|
||||
>
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{
|
||||
t('admin.settings.registration.totp')
|
||||
}}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.registration.totpHint') }}
|
||||
</p>
|
||||
<!-- Warning when encryption key not configured -->
|
||||
<p
|
||||
v-if="!form.totp_encryption_key_configured"
|
||||
class="mt-2 text-sm text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
{{ t('admin.settings.registration.totpKeyNotConfigured') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
v-model="form.totp_enabled"
|
||||
:disabled="!form.totp_encryption_key_configured"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1046,6 +1071,8 @@ const form = reactive<SettingsForm>({
|
||||
email_verify_enabled: false,
|
||||
promo_code_enabled: true,
|
||||
password_reset_enabled: false,
|
||||
totp_enabled: false,
|
||||
totp_encryption_key_configured: false,
|
||||
default_balance: 0,
|
||||
default_concurrency: 1,
|
||||
site_name: 'Sub2API',
|
||||
@@ -1170,6 +1197,7 @@ async function saveSettings() {
|
||||
email_verify_enabled: form.email_verify_enabled,
|
||||
promo_code_enabled: form.promo_code_enabled,
|
||||
password_reset_enabled: form.password_reset_enabled,
|
||||
totp_enabled: form.totp_enabled,
|
||||
default_balance: form.default_balance,
|
||||
default_concurrency: form.default_concurrency,
|
||||
site_name: form.site_name,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
</div>
|
||||
<ProfileEditForm :initial-username="user?.username || ''" />
|
||||
<ProfilePasswordForm />
|
||||
<ProfileTotpCard />
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -27,6 +28,7 @@ import StatCard from '@/components/common/StatCard.vue'
|
||||
import ProfileInfoCard from '@/components/user/profile/ProfileInfoCard.vue'
|
||||
import ProfileEditForm from '@/components/user/profile/ProfileEditForm.vue'
|
||||
import ProfilePasswordForm from '@/components/user/profile/ProfilePasswordForm.vue'
|
||||
import ProfileTotpCard from '@/components/user/profile/ProfileTotpCard.vue'
|
||||
import { Icon } from '@/components/icons'
|
||||
|
||||
const { t } = useI18n(); const authStore = useAuthStore(); const user = computed(() => authStore.user)
|
||||
|
||||
Reference in New Issue
Block a user