mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-07-29 14:41:45 +08:00
feat: 添加 TOTP 2FA 功能
This commit is contained in:
21
web/package-lock.json
generated
21
web/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
338
web/src/components/TotpSettingsCard.vue
Normal file
338
web/src/components/TotpSettingsCard.vue
Normal 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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '绑定地址',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user