feat: 添加 TOTP 2FA 功能

This commit is contained in:
mofeng-git
2026-07-17 16:34:06 +08:00
parent fdea52d59d
commit 8bd16df6f8
27 changed files with 1505 additions and 54 deletions

21
web/package-lock.json generated
View File

@@ -14,6 +14,7 @@
"lucide-vue-next": "^0.556.0",
"opus-decoder": "^0.7.11",
"pinia": "^3.0.4",
"qrcode.vue": "^3.10.0",
"reka-ui": "^2.10.1",
"simple-keyboard": "^3.8.163",
"tailwind-merge": "^3.6.0",
@@ -85,6 +86,17 @@
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
@@ -2556,6 +2568,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/qrcode.vue": {
"version": "3.10.0",
"resolved": "https://registry.npmmirror.com/qrcode.vue/-/qrcode.vue-3.10.0.tgz",
"integrity": "sha512-1bjeBds9hRKMszZuBuYQZor9HdJYWtb0S44HG1JofZ9uicpJpdF+TtDvqo3+2ZlH0k80WVIkmiKB4hq0/N6/rA==",
"license": "MIT",
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/reka-ui": {
"version": "2.10.1",
"resolved": "https://registry.npmmirror.com/reka-ui/-/reka-ui-2.10.1.tgz",

View File

@@ -15,6 +15,7 @@
"lucide-vue-next": "^0.556.0",
"opus-decoder": "^0.7.11",
"pinia": "^3.0.4",
"qrcode.vue": "^3.10.0",
"reka-ui": "^2.10.1",
"simple-keyboard": "^3.8.163",
"tailwind-merge": "^3.6.0",

View File

@@ -15,7 +15,7 @@ const API_BASE = '/api'
export const authApi = {
login: (username: string, password: string) =>
request<{ success: boolean; message?: string }>(
request<AuthLoginResponse>(
'/auth/login',
{
method: 'POST',
@@ -24,6 +24,16 @@ export const authApi = {
{ toastOnError: false },
),
loginTotp: (challengeId: string, code: string) =>
request<AuthLoginResponse>(
'/auth/login/totp',
{
method: 'POST',
body: JSON.stringify({ challenge_id: challengeId, code }),
},
{ toastOnError: false },
),
logout: () =>
request<{ success: boolean }>('/auth/logout', { method: 'POST' }),
@@ -41,6 +51,46 @@ export const authApi = {
method: 'POST',
body: JSON.stringify({ username, current_password: currentPassword }),
}),
totpStatus: () =>
request<TotpStatusResponse>('/auth/totp'),
beginTotpEnrollment: (currentPassword: string) =>
request<TotpEnrollmentResponse>('/auth/totp/enrollment', {
method: 'POST',
body: JSON.stringify({ current_password: currentPassword }),
}, { toastOnError: false }),
confirmTotpEnrollment: (enrollmentId: string, code: string) =>
request<{ success: boolean }>('/auth/totp/enrollment/confirm', {
method: 'POST',
body: JSON.stringify({ enrollment_id: enrollmentId, code }),
}, { toastOnError: false }),
disableTotp: (currentPassword: string, code: string) =>
request<{ success: boolean }>('/auth/totp/disable', {
method: 'POST',
body: JSON.stringify({ current_password: currentPassword, code }),
}, { toastOnError: false }),
}
export interface AuthLoginResponse {
next: 'authenticated' | 'totp'
challenge_id?: string
expires_at_unix_ms?: number
}
export interface TotpStatusResponse {
enabled: boolean
server_time_unix_ms: number
}
export interface TotpEnrollmentResponse {
enrollment_id: string
secret: string
otpauth_uri: string
expires_at_unix_ms: number
server_time_unix_ms: number
}
export interface NetworkAddress {

View File

@@ -0,0 +1,338 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import QrcodeVue from 'qrcode.vue'
import { authApi } from '@/api'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { AlertTriangle, KeyRound, Loader2, ShieldCheck, ShieldOff } from 'lucide-vue-next'
const { t, locale } = useI18n()
const enabled = ref(false)
const statusLoading = ref(true)
const statusError = ref('')
const now = ref(Date.now())
const serverOffsetMs = ref(0)
const nextSyncAt = ref(0)
const enrollmentOpen = ref(false)
const enrollmentStep = ref<'password' | 'verify'>('password')
const enrollmentLoading = ref(false)
const enrollmentError = ref('')
const enrollmentPassword = ref('')
const enrollmentCode = ref('')
const enrollmentId = ref('')
const enrollmentSecret = ref('')
const enrollmentUri = ref('')
const enrollmentExpiresAt = ref(0)
const disableOpen = ref(false)
const disableLoading = ref(false)
const disableError = ref('')
const disablePassword = ref('')
const disableCode = ref('')
const serverTime = computed(() => formatDate(now.value + serverOffsetMs.value, true))
const localTime = computed(() => formatDate(now.value, false))
const clockOffsetSeconds = computed(() => Math.round(serverOffsetMs.value / 1000))
const clockDriftWarning = computed(() => Math.abs(serverOffsetMs.value) >= 30_000)
let timer: ReturnType<typeof setInterval> | undefined
let statusSyncing = false
function formatDate(timestamp: number, utc: boolean) {
return new Intl.DateTimeFormat(locale.value, {
dateStyle: 'medium',
timeStyle: 'medium',
...(utc ? { timeZone: 'UTC' } : {}),
}).format(new Date(timestamp))
}
function applyServerTime(serverTimeUnixMs: number, requestStartedAt: number) {
const localMidpoint = Math.round((requestStartedAt + Date.now()) / 2)
serverOffsetMs.value = serverTimeUnixMs - localMidpoint
nextSyncAt.value = Date.now() + 60_000
}
async function loadStatus() {
if (statusSyncing) return
statusSyncing = true
const startedAt = Date.now()
try {
const status = await authApi.totpStatus()
enabled.value = status.enabled
applyServerTime(status.server_time_unix_ms, startedAt)
statusError.value = ''
} catch {
statusError.value = t('settings.totp.loadFailed')
nextSyncAt.value = Date.now() + 60_000
} finally {
statusSyncing = false
statusLoading.value = false
}
}
function normalizeCode(value: string) {
return value.replace(/\D/g, '').slice(0, 6)
}
function localizedError(error: unknown) {
const raw = error instanceof Error ? error.message : ''
if (raw.includes('Current password is incorrect')) return t('settings.totp.currentPasswordIncorrect')
if (raw.includes('Invalid TOTP code')) return t('settings.totp.invalidCode')
if (raw.includes('enrollment expired')) return t('settings.totp.enrollmentExpired')
if (raw.includes('Too many attempts')) return t('settings.totp.rateLimited')
if (raw.includes('already enabled')) return t('settings.totp.alreadyEnabled')
if (raw.includes('not enabled')) return t('settings.totp.notEnabled')
return raw || t('settings.totp.operationFailed')
}
async function beginEnrollment() {
if (!enrollmentPassword.value) {
enrollmentError.value = t('auth.enterPassword')
return
}
enrollmentLoading.value = true
enrollmentError.value = ''
const startedAt = Date.now()
try {
const result = await authApi.beginTotpEnrollment(enrollmentPassword.value)
enrollmentPassword.value = ''
enrollmentId.value = result.enrollment_id
enrollmentSecret.value = result.secret
enrollmentUri.value = result.otpauth_uri
enrollmentExpiresAt.value = result.expires_at_unix_ms
applyServerTime(result.server_time_unix_ms, startedAt)
enrollmentStep.value = 'verify'
} catch (error) {
enrollmentError.value = localizedError(error)
} finally {
enrollmentLoading.value = false
}
}
async function confirmEnrollment() {
enrollmentCode.value = normalizeCode(enrollmentCode.value)
if (enrollmentCode.value.length !== 6) {
enrollmentError.value = t('settings.totp.enterSixDigitCode')
return
}
enrollmentLoading.value = true
enrollmentError.value = ''
try {
await authApi.confirmTotpEnrollment(enrollmentId.value, enrollmentCode.value)
enabled.value = true
enrollmentOpen.value = false
} catch (error) {
enrollmentError.value = localizedError(error)
enrollmentCode.value = ''
if (error instanceof Error && error.message.includes('enrollment expired')) {
clearEnrollmentSecret()
enrollmentStep.value = 'password'
}
} finally {
enrollmentLoading.value = false
}
}
async function disableTotp() {
disableCode.value = normalizeCode(disableCode.value)
if (!disablePassword.value) {
disableError.value = t('auth.enterPassword')
return
}
if (disableCode.value.length !== 6) {
disableError.value = t('settings.totp.enterSixDigitCode')
return
}
disableLoading.value = true
disableError.value = ''
try {
await authApi.disableTotp(disablePassword.value, disableCode.value)
enabled.value = false
disableOpen.value = false
} catch (error) {
disableError.value = localizedError(error)
disableCode.value = ''
} finally {
disableLoading.value = false
}
}
function clearEnrollmentSecret() {
enrollmentId.value = ''
enrollmentSecret.value = ''
enrollmentUri.value = ''
enrollmentExpiresAt.value = 0
enrollmentCode.value = ''
}
function resetEnrollment() {
clearEnrollmentSecret()
enrollmentPassword.value = ''
enrollmentError.value = ''
enrollmentStep.value = 'password'
}
function resetDisable() {
disablePassword.value = ''
disableCode.value = ''
disableError.value = ''
}
watch(enrollmentOpen, (open) => {
if (!open) resetEnrollment()
})
watch(disableOpen, (open) => {
if (!open) resetDisable()
})
onMounted(async () => {
await loadStatus()
timer = setInterval(() => {
now.value = Date.now()
if (enrollmentId.value && now.value >= enrollmentExpiresAt.value) {
clearEnrollmentSecret()
enrollmentStep.value = 'password'
enrollmentError.value = t('settings.totp.enrollmentExpired')
}
if (now.value >= nextSyncAt.value) void loadStatus()
}, 1000)
})
onUnmounted(() => {
if (timer) clearInterval(timer)
resetEnrollment()
resetDisable()
})
</script>
<template>
<Card>
<CardHeader class="flex flex-row items-start justify-between gap-4 space-y-0">
<div class="space-y-1.5">
<CardTitle>{{ t('settings.totp.title') }}</CardTitle>
<CardDescription>{{ t('settings.totp.description') }}</CardDescription>
</div>
<Badge :variant="enabled ? 'default' : 'secondary'">
{{ enabled ? t('common.enabled') : t('common.disabled') }}
</Badge>
</CardHeader>
<CardContent class="space-y-4">
<div class="grid gap-3 text-sm sm:grid-cols-2">
<div class="space-y-1">
<p class="text-xs text-muted-foreground">{{ t('settings.totp.serverTime') }}</p>
<p class="font-mono">{{ serverTime }} UTC</p>
</div>
<div class="space-y-1">
<p class="text-xs text-muted-foreground">{{ t('settings.totp.localTime') }}</p>
<p class="font-mono">{{ localTime }}</p>
</div>
</div>
<p class="text-xs text-muted-foreground">
{{ t('settings.totp.clockOffset', { seconds: clockOffsetSeconds }) }}
</p>
<Alert v-if="clockDriftWarning" variant="destructive">
<AlertTriangle />
<AlertDescription>{{ t('settings.totp.clockDriftWarning') }}</AlertDescription>
</Alert>
<Alert v-if="statusError" variant="destructive">
<AlertTriangle />
<AlertDescription>{{ statusError }}</AlertDescription>
</Alert>
</CardContent>
<CardFooter class="border-t pt-4 justify-end">
<Button v-if="!enabled" :disabled="statusLoading" @click="enrollmentOpen = true">
<ShieldCheck class="h-4 w-4" />
{{ t('settings.totp.enable') }}
</Button>
<Button v-else variant="destructive" :disabled="statusLoading" @click="disableOpen = true">
<ShieldOff class="h-4 w-4" />
{{ t('settings.totp.disable') }}
</Button>
</CardFooter>
</Card>
<Dialog v-model:open="enrollmentOpen">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>{{ t('settings.totp.enableTitle') }}</DialogTitle>
</DialogHeader>
<div v-if="enrollmentStep === 'password'" class="space-y-4">
<div class="space-y-2">
<Label for="totp-enrollment-password">{{ t('settings.currentPassword') }}</Label>
<Input id="totp-enrollment-password" v-model="enrollmentPassword" type="password" autocomplete="current-password" />
</div>
</div>
<div v-else class="space-y-4">
<div class="flex justify-center rounded-md border bg-white p-3">
<QrcodeVue :value="enrollmentUri" :size="200" level="M" />
</div>
<div class="space-y-2">
<Label>{{ t('settings.totp.manualSecret') }}</Label>
<Input :model-value="enrollmentSecret" readonly class="font-mono" />
<p class="text-xs text-muted-foreground">{{ t('settings.totp.secretOneTime') }}</p>
</div>
<div class="space-y-2">
<Label for="totp-enrollment-code">{{ t('auth.totpCode') }}</Label>
<div class="relative">
<KeyRound class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input id="totp-enrollment-code" v-model="enrollmentCode" inputmode="numeric" autocomplete="one-time-code" maxlength="6" class="pl-10 font-mono" @input="enrollmentCode = normalizeCode(enrollmentCode)" />
</div>
</div>
</div>
<Alert v-if="enrollmentError" variant="destructive">
<AlertTriangle />
<AlertDescription>{{ enrollmentError }}</AlertDescription>
</Alert>
<DialogFooter>
<Button variant="outline" :disabled="enrollmentLoading" @click="enrollmentOpen = false">{{ t('common.cancel') }}</Button>
<Button v-if="enrollmentStep === 'password'" :disabled="enrollmentLoading" @click="beginEnrollment">
<Loader2 v-if="enrollmentLoading" class="h-4 w-4 animate-spin" />
{{ t('common.next') }}
</Button>
<Button v-else :disabled="enrollmentLoading" @click="confirmEnrollment">
<Loader2 v-if="enrollmentLoading" class="h-4 w-4 animate-spin" />
{{ t('common.confirm') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="disableOpen">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>{{ t('settings.totp.disableTitle') }}</DialogTitle>
</DialogHeader>
<div class="space-y-4">
<div class="space-y-2">
<Label for="totp-disable-password">{{ t('settings.currentPassword') }}</Label>
<Input id="totp-disable-password" v-model="disablePassword" type="password" autocomplete="current-password" />
</div>
<div class="space-y-2">
<Label for="totp-disable-code">{{ t('auth.totpCode') }}</Label>
<Input id="totp-disable-code" v-model="disableCode" inputmode="numeric" autocomplete="one-time-code" maxlength="6" class="font-mono" @input="disableCode = normalizeCode(disableCode)" />
</div>
<Alert v-if="disableError" variant="destructive">
<AlertTriangle />
<AlertDescription>{{ disableError }}</AlertDescription>
</Alert>
</div>
<DialogFooter>
<Button variant="outline" :disabled="disableLoading" @click="disableOpen = false">{{ t('common.cancel') }}</Button>
<Button variant="destructive" :disabled="disableLoading" @click="disableTotp">
<Loader2 v-if="disableLoading" class="h-4 w-4 animate-spin" />
{{ t('settings.totp.disable') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View File

@@ -74,6 +74,15 @@ export default {
forgotPassword: 'Forgot password',
forgotPasswordHint:
'Forgot your password? On the host running One-KVM, open a terminal and run one-kvm user set-password, then enter and confirm the new password when prompted.',
totpPrompt: 'Enter the code from your authenticator app',
totpCode: 'Authenticator code',
totpCodePlaceholder: '6-digit code',
enterTotpCode: 'Enter a 6-digit authenticator code',
invalidTotpCode: 'The authenticator code is incorrect',
totpChallengeExpired: 'This login attempt has expired. Enter your password again.',
totpRateLimited: 'Too many incorrect codes. Wait one minute, then sign in again.',
verifyAndLogin: 'Verify and login',
backToPassword: 'Back to password',
},
status: {
connected: 'Connected',
@@ -469,6 +478,29 @@ export default {
newPassword: 'New Password',
usernameDesc: 'Change the console login username',
passwordDesc: 'Change the console login password',
totp: {
title: 'Two-factor authentication',
description: 'Require an authenticator code when signing in to the web console',
serverTime: 'Server time',
localTime: 'Local time',
clockOffset: 'Clock offset: {seconds} seconds',
clockDriftWarning: 'The server and this device differ by at least 30 seconds. Codes may fail until their clocks are corrected.',
enable: 'Enable 2FA',
disable: 'Disable 2FA',
enableTitle: 'Set up two-factor authentication',
disableTitle: 'Disable two-factor authentication',
manualSecret: 'Manual setup key',
secretOneTime: 'This key is shown only during setup. Store it in your authenticator now.',
enterSixDigitCode: 'Enter a 6-digit authenticator code',
currentPasswordIncorrect: 'The current password is incorrect',
invalidCode: 'The authenticator code is incorrect',
enrollmentExpired: 'Setup expired. Enter your password to start again.',
rateLimited: 'Too many incorrect codes. Wait one minute and try again.',
alreadyEnabled: 'Two-factor authentication is already enabled',
notEnabled: 'Two-factor authentication is not enabled',
operationFailed: 'Unable to update two-factor authentication',
loadFailed: 'Unable to load two-factor authentication status',
},
httpPort: 'HTTP Port',
httpsPort: 'HTTPS Port',
bindAddress: 'Bind Address',

View File

@@ -74,6 +74,15 @@ export default {
forgotPassword: '忘记密码',
forgotPasswordHint:
'忘记密码?在运行本服务的设备上打开终端,执行 one-kvm user set-password按提示输入并确认新密码即可重置。',
totpPrompt: '请输入认证器应用中的验证码',
totpCode: '认证器验证码',
totpCodePlaceholder: '6 位验证码',
enterTotpCode: '请输入 6 位认证器验证码',
invalidTotpCode: '认证器验证码错误',
totpChallengeExpired: '本次登录验证已过期,请重新输入密码。',
totpRateLimited: '错误次数过多,请等待一分钟后重新登录。',
verifyAndLogin: '验证并登录',
backToPassword: '返回密码登录',
},
status: {
connected: '已连接',
@@ -468,6 +477,29 @@ export default {
newPassword: '新密码',
usernameDesc: '修改控制台登录用户名',
passwordDesc: '修改控制台登录密码',
totp: {
title: '双重身份验证',
description: '登录 Web 控制台时要求输入认证器验证码',
serverTime: '服务器时间',
localTime: '本地时间',
clockOffset: '时钟偏差:{seconds} 秒',
clockDriftWarning: '服务器与当前设备的时间相差至少 30 秒,校准时钟前验证码可能无法通过。',
enable: '启用 2FA',
disable: '关闭 2FA',
enableTitle: '设置双重身份验证',
disableTitle: '关闭双重身份验证',
manualSecret: '手动设置密钥',
secretOneTime: '此密钥仅在设置期间显示,请立即添加到认证器。',
enterSixDigitCode: '请输入 6 位认证器验证码',
currentPasswordIncorrect: '当前密码错误',
invalidCode: '认证器验证码错误',
enrollmentExpired: '设置已过期,请重新输入密码开始。',
rateLimited: '错误次数过多,请等待一分钟后重试。',
alreadyEnabled: '双重身份验证已经启用',
notEnabled: '双重身份验证尚未启用',
operationFailed: '无法更新双重身份验证',
loadFailed: '无法加载双重身份验证状态',
},
httpPort: 'HTTP 端口',
httpsPort: 'HTTPS 端口',
bindAddress: '绑定地址',

View File

@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { authApi, systemApi } from '@/api'
import { authApi, systemApi, type AuthLoginResponse } from '@/api'
export const useAuthStore = defineStore('auth', () => {
const user = ref<string | null>(null)
@@ -9,6 +9,7 @@ export const useAuthStore = defineStore('auth', () => {
const needsSetup = ref(false)
const loading = ref(false)
const error = ref<string | null>(null)
let pendingUsername: string | null = null
const isLoggedIn = computed(() => isAuthenticated.value && user.value !== null)
@@ -41,20 +42,44 @@ export const useAuthStore = defineStore('auth', () => {
}
}
async function login(username: string, password: string) {
async function beginLogin(username: string, password: string): Promise<AuthLoginResponse | null> {
loading.value = true
error.value = null
try {
const result = await authApi.login(username, password)
if (result.success) {
if (result.next === 'authenticated') {
isAuthenticated.value = true
user.value = username
return true
pendingUsername = null
} else {
error.value = result.message || 'Login failed'
isAuthenticated.value = false
user.value = null
pendingUsername = username
}
return result
} catch (e) {
error.value = e instanceof Error ? e.message : 'Login failed'
pendingUsername = null
return null
} finally {
loading.value = false
}
}
async function completeTotpLogin(challengeId: string, code: string) {
loading.value = true
error.value = null
try {
const result = await authApi.loginTotp(challengeId, code)
if (result.next !== 'authenticated') {
error.value = 'Login failed'
return false
}
isAuthenticated.value = true
user.value = pendingUsername
pendingUsername = null
return true
} catch (e) {
error.value = e instanceof Error ? e.message : 'Login failed'
return false
@@ -63,6 +88,16 @@ export const useAuthStore = defineStore('auth', () => {
}
}
async function login(username: string, password: string) {
const result = await beginLogin(username, password)
return result?.next === 'authenticated'
}
function cancelPendingLogin() {
pendingUsername = null
error.value = null
}
async function logout() {
try {
await authApi.logout()
@@ -123,6 +158,9 @@ export const useAuthStore = defineStore('auth', () => {
isLoggedIn,
checkSetupStatus,
checkAuth,
beginLogin,
completeTotpLogin,
cancelPendingLogin,
login,
logout,
setup,

View File

@@ -5,8 +5,6 @@
export interface AuthConfig {
session_timeout_secs: number;
single_user_allow_multiple_sessions: boolean;
totp_enabled: boolean;
totp_secret?: string;
}
export interface VideoConfig {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue'
import { nextTick, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
@@ -11,7 +11,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert'
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
import BrandMark from '@/components/BrandMark.vue'
import { AlertCircle, Lock, Eye, EyeOff, User, CircleHelp } from 'lucide-vue-next'
import { AlertCircle, ArrowLeft, KeyRound, Lock, Eye, EyeOff, User, CircleHelp } from 'lucide-vue-next'
const { t } = useI18n()
const router = useRouter()
@@ -23,6 +23,9 @@ function localizedLoginError(raw: string | null): string {
if (!raw) return t('auth.loginFailed')
if (raw.includes('Invalid username or password')) return t('auth.invalidPassword')
if (raw.includes('System not initialized')) return t('auth.systemNotInitialized')
if (raw.includes('Invalid TOTP code')) return t('auth.invalidTotpCode')
if (raw.includes('challenge expired')) return t('auth.totpChallengeExpired')
if (raw.includes('Too many attempts')) return t('auth.totpRateLimited')
return raw
}
@@ -31,6 +34,14 @@ const password = ref('')
const showPassword = ref(false)
const loading = ref(false)
const error = ref('')
const step = ref<'password' | 'totp'>('password')
const challengeId = ref('')
const challengeExpiresAt = ref(0)
const totpCode = ref('')
function focusTotpInput() {
document.querySelector<HTMLInputElement>('#totp-code')?.focus()
}
async function handleLogin() {
if (!username.value) {
@@ -45,17 +56,67 @@ async function handleLogin() {
loading.value = true
error.value = ''
const success = await authStore.login(username.value, password.value)
const result = await authStore.beginLogin(username.value, password.value)
if (success) {
if (result?.next === 'authenticated') {
const redirect = route.query.redirect as string
router.push(redirect || '/')
} else if (result?.next === 'totp' && result.challenge_id && result.expires_at_unix_ms) {
password.value = ''
challengeId.value = result.challenge_id
challengeExpiresAt.value = result.expires_at_unix_ms
step.value = 'totp'
await nextTick()
focusTotpInput()
} else {
error.value = localizedLoginError(authStore.error)
}
loading.value = false
}
function normalizeTotpCode() {
totpCode.value = totpCode.value.replace(/\D/g, '').slice(0, 6)
}
async function handleTotpLogin() {
normalizeTotpCode()
if (Date.now() >= challengeExpiresAt.value) {
error.value = t('auth.totpChallengeExpired')
returnToPassword(true)
return
}
if (totpCode.value.length !== 6) {
error.value = t('auth.enterTotpCode')
return
}
loading.value = true
error.value = ''
const success = await authStore.completeTotpLogin(challengeId.value, totpCode.value)
if (success) {
const redirect = route.query.redirect as string
router.push(redirect || '/')
} else {
error.value = localizedLoginError(authStore.error)
if (authStore.error?.includes('challenge expired') || authStore.error?.includes('Too many attempts')) {
returnToPassword(true)
} else {
totpCode.value = ''
await nextTick()
focusTotpInput()
}
}
loading.value = false
}
function returnToPassword(keepError = false) {
step.value = 'password'
challengeId.value = ''
challengeExpiresAt.value = 0
totpCode.value = ''
authStore.cancelPendingLogin()
if (!keepError) error.value = ''
}
</script>
<template>
@@ -70,11 +131,11 @@ async function handleLogin() {
<BrandMark size="xl" />
</div>
<CardTitle class="text-xl sm:text-2xl">One-KVM</CardTitle>
<CardDescription>{{ t('auth.login') }}</CardDescription>
<CardDescription>{{ step === 'password' ? t('auth.login') : t('auth.totpPrompt') }}</CardDescription>
</CardHeader>
<CardContent>
<form @submit.prevent="handleLogin">
<form v-if="step === 'password'" @submit.prevent="handleLogin">
<FieldGroup>
<Field>
<FieldLabel for="username">{{ t('auth.username') }}</FieldLabel>
@@ -149,6 +210,41 @@ async function handleLogin() {
</FieldGroup>
</form>
<form v-else @submit.prevent="handleTotpLogin">
<FieldGroup>
<Field>
<FieldLabel for="totp-code">{{ t('auth.totpCode') }}</FieldLabel>
<div class="relative">
<KeyRound class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="totp-code"
v-model="totpCode"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="6"
class="pl-10 font-mono text-lg"
:placeholder="t('auth.totpCodePlaceholder')"
@input="normalizeTotpCode"
/>
</div>
</Field>
<Alert v-if="error" variant="destructive">
<AlertCircle />
<AlertDescription>{{ error }}</AlertDescription>
</Alert>
<Button type="submit" class="w-full" :disabled="loading">
<span v-if="loading">{{ t('common.loading') }}</span>
<span v-else>{{ t('auth.verifyAndLogin') }}</span>
</Button>
<Button type="button" variant="ghost" class="w-full" :disabled="loading" @click="returnToPassword()">
<ArrowLeft class="h-4 w-4" />
{{ t('auth.backToPassword') }}
</Button>
</FieldGroup>
</form>
</CardContent>
</Card>
</div>

View File

@@ -62,6 +62,7 @@ import { formatVideoDeviceLabel } from '@/lib/video-device-label'
import AppLayout from '@/components/AppLayout.vue'
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
import TerminalDialog from '@/components/TerminalDialog.vue'
import TotpSettingsCard from '@/components/TotpSettingsCard.vue'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
@@ -331,8 +332,6 @@ const showPasswords = ref(false)
const authConfig = ref<AuthConfig>({
session_timeout_secs: 3600 * 24,
single_user_allow_multiple_sessions: false,
totp_enabled: false,
totp_secret: undefined,
})
const authConfigLoading = ref(false)
@@ -2868,6 +2867,8 @@ watch(isWindows, () => {
</CardFooter>
</Card>
<TotpSettingsCard />
<Card>
<CardHeader>
<CardTitle>{{ t('settings.authSettings') }}</CardTitle>