mirror of
https://github.com/0xJacky/nginx-ui.git
synced 2025-05-12 19:05:55 +02:00
feat: 2FA authorization for web terminal
This commit is contained in:
parent
802d05f692
commit
3a22861640
15 changed files with 359 additions and 54 deletions
|
@ -18,6 +18,12 @@ const otp = {
|
|||
reset(recovery_code: string) {
|
||||
return http.post('/otp_reset', { recovery_code })
|
||||
},
|
||||
start_secure_session(passcode: string, recovery_code: string): Promise<{ session_id: string }> {
|
||||
return http.post('/otp_secure_session', {
|
||||
otp: passcode,
|
||||
recovery_code,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default otp
|
||||
|
|
78
app/src/components/OTP/OTPAuthorization.vue
Normal file
78
app/src/components/OTP/OTPAuthorization.vue
Normal file
|
@ -0,0 +1,78 @@
|
|||
<script setup lang="ts">
|
||||
import OTPInput from '@/components/OTPInput/OTPInput.vue'
|
||||
|
||||
const emit = defineEmits(['onSubmit'])
|
||||
|
||||
const refOTP = ref()
|
||||
const useRecoveryCode = ref(false)
|
||||
const passcode = ref('')
|
||||
const recoveryCode = ref('')
|
||||
|
||||
function clickUseRecoveryCode() {
|
||||
passcode.value = ''
|
||||
useRecoveryCode.value = true
|
||||
}
|
||||
|
||||
function clickUseOTP() {
|
||||
passcode.value = ''
|
||||
useRecoveryCode.value = false
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
emit('onSubmit', passcode.value, recoveryCode.value)
|
||||
}
|
||||
|
||||
function clearInput() {
|
||||
refOTP.value?.clearInput()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
clearInput,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="!useRecoveryCode">
|
||||
<p>{{ $gettext('Please enter the 2FA code:') }}</p>
|
||||
<OTPInput
|
||||
ref="refOTP"
|
||||
v-model="passcode"
|
||||
class="justify-center mb-6"
|
||||
@on-complete="onSubmit"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="mt-2 mb-4"
|
||||
>
|
||||
<p>{{ $gettext('Input the recovery code:') }}</p>
|
||||
<AInputGroup compact>
|
||||
<AInput v-model:value="recoveryCode" />
|
||||
<AButton
|
||||
type="primary"
|
||||
@click="onSubmit"
|
||||
>
|
||||
{{ $gettext('Recovery') }}
|
||||
</AButton>
|
||||
</AInputGroup>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<a
|
||||
v-if="!useRecoveryCode"
|
||||
@click="clickUseRecoveryCode"
|
||||
>{{ $gettext('Use recovery code') }}</a>
|
||||
<a
|
||||
v-else
|
||||
@click="clickUseOTP"
|
||||
>{{ $gettext('Use OTP') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="less">
|
||||
:deep(.ant-input-group.ant-input-group-compact) {
|
||||
display: flex;
|
||||
}
|
||||
</style>
|
75
app/src/components/OTP/useOTPModal.ts
Normal file
75
app/src/components/OTP/useOTPModal.ts
Normal file
|
@ -0,0 +1,75 @@
|
|||
import { createVNode, render } from 'vue'
|
||||
import { Modal, message } from 'ant-design-vue'
|
||||
import OTPAuthorization from '@/components/OTP/OTPAuthorization.vue'
|
||||
import otp from '@/api/otp'
|
||||
|
||||
export interface OTPModalProps {
|
||||
onOk?: (secureSessionId: string) => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
const useOTPModal = () => {
|
||||
const refOTPAuthorization = ref<typeof OTPAuthorization>()
|
||||
const randomId = Math.random().toString(36).substring(2, 8)
|
||||
|
||||
const injectStyles = () => {
|
||||
const style = document.createElement('style')
|
||||
|
||||
style.innerHTML = `
|
||||
.${randomId} .ant-modal-title {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
|
||||
const open = ({ onOk, onCancel }: OTPModalProps) => {
|
||||
injectStyles()
|
||||
let container: HTMLDivElement | null = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
|
||||
const close = () => {
|
||||
render(null, container!)
|
||||
document.body.removeChild(container!)
|
||||
container = null
|
||||
}
|
||||
|
||||
const verify = (passcode: string, recovery: string) => {
|
||||
otp.start_secure_session(passcode, recovery).then(r => {
|
||||
onOk?.(r.session_id)
|
||||
close()
|
||||
}).catch(async () => {
|
||||
refOTPAuthorization.value?.clearInput()
|
||||
await message.error($gettext('Invalid passcode or recovery code'))
|
||||
})
|
||||
}
|
||||
|
||||
const vnode = createVNode(Modal, {
|
||||
open: true,
|
||||
title: $gettext('Two-factor authentication required'),
|
||||
centered: true,
|
||||
maskClosable: false,
|
||||
class: randomId,
|
||||
footer: false,
|
||||
onCancel: () => {
|
||||
close()
|
||||
onCancel?.()
|
||||
},
|
||||
}, {
|
||||
default: () => h(
|
||||
OTPAuthorization,
|
||||
{
|
||||
ref: refOTPAuthorization,
|
||||
class: 'mt-3',
|
||||
onOnSubmit: verify,
|
||||
},
|
||||
),
|
||||
})
|
||||
|
||||
render(vnode, container)
|
||||
}
|
||||
|
||||
return { open }
|
||||
}
|
||||
|
||||
export default useOTPModal
|
|
@ -1,14 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import { LockOutlined, UserOutlined } from '@ant-design/icons-vue'
|
||||
import { Form, message } from 'ant-design-vue'
|
||||
import OTPInput from '@/components/OTPInput/OTPInput.vue'
|
||||
|
||||
import { useUserStore } from '@/pinia'
|
||||
import auth from '@/api/auth'
|
||||
import install from '@/api/install'
|
||||
import SetLanguage from '@/components/SetLanguage/SetLanguage.vue'
|
||||
import SwitchAppearance from '@/components/SwitchAppearance/SwitchAppearance.vue'
|
||||
import gettext, { $gettext } from '@/gettext'
|
||||
import OTPAuthorization from '@/components/OTP/OTPAuthorization.vue'
|
||||
|
||||
const thisYear = new Date().getFullYear()
|
||||
|
||||
|
@ -24,7 +23,6 @@ const loading = ref(false)
|
|||
const enabled2FA = ref(false)
|
||||
const refOTP = ref()
|
||||
const passcode = ref('')
|
||||
const useRecoveryCode = ref(false)
|
||||
const recoveryCode = ref('')
|
||||
|
||||
const modelRef = reactive({
|
||||
|
@ -135,9 +133,13 @@ if (route.query?.code !== undefined && route.query?.state !== undefined) {
|
|||
loading.value = false
|
||||
}
|
||||
|
||||
function clickUseRecoveryCode() {
|
||||
passcode.value = ''
|
||||
useRecoveryCode.value = true
|
||||
function handleOTPSubmit(code: string, recovery: string) {
|
||||
passcode.value = code
|
||||
recoveryCode.value = recovery
|
||||
|
||||
nextTick(() => {
|
||||
onSubmit()
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -173,38 +175,10 @@ function clickUseRecoveryCode() {
|
|||
</AFormItem>
|
||||
</template>
|
||||
<div v-else>
|
||||
<div v-if="!useRecoveryCode">
|
||||
<p>{{ $gettext('Please enter the 2FA code:') }}</p>
|
||||
<OTPInput
|
||||
ref="refOTP"
|
||||
v-model="passcode"
|
||||
class="justify-center mb-6"
|
||||
@on-complete="onSubmit"
|
||||
/>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<a @click="clickUseRecoveryCode">{{ $gettext('Use recovery code') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="mt-2"
|
||||
>
|
||||
<p>{{ $gettext('Input the recovery code:') }}</p>
|
||||
<AInputGroup compact>
|
||||
<AInput
|
||||
v-model:value="recoveryCode"
|
||||
style="width: calc(100% - 92px)"
|
||||
/>
|
||||
<AButton
|
||||
type="primary"
|
||||
@click="onSubmit"
|
||||
>
|
||||
{{ $gettext('Recovery') }}
|
||||
</AButton>
|
||||
</AInputGroup>
|
||||
</div>
|
||||
<OTPAuthorization
|
||||
ref="refOTP"
|
||||
@on-submit="handleOTPSubmit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AFormItem v-if="!enabled2FA">
|
||||
|
|
|
@ -2,20 +2,43 @@
|
|||
import '@xterm/xterm/css/xterm.css'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import _ from 'lodash'
|
||||
import ws from '@/lib/websocket'
|
||||
import useOTPModal from '@/components/OTP/useOTPModal'
|
||||
|
||||
let term: Terminal | null
|
||||
let ping: NodeJS.Timeout
|
||||
|
||||
const websocket = ws('/api/pty')
|
||||
const router = useRouter()
|
||||
const websocket = shallowRef()
|
||||
const lostConnection = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
initTerm()
|
||||
const otpModal = useOTPModal()
|
||||
|
||||
websocket.onmessage = wsOnMessage
|
||||
websocket.onopen = wsOnOpen
|
||||
otpModal.open({
|
||||
onOk(secureSessionId: string) {
|
||||
websocket.value = ws(`/api/pty?X-Secure-Session-ID=${secureSessionId}`, false)
|
||||
|
||||
nextTick(() => {
|
||||
initTerm()
|
||||
websocket.value.onmessage = wsOnMessage
|
||||
websocket.value.onopen = wsOnOpen
|
||||
websocket.value.onerror = () => {
|
||||
lostConnection.value = true
|
||||
}
|
||||
websocket.value.onclose = () => {
|
||||
lostConnection.value = true
|
||||
}
|
||||
})
|
||||
},
|
||||
onCancel() {
|
||||
if (window.history.length > 1)
|
||||
router.go(-1)
|
||||
else
|
||||
router.push('/')
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
interface Message {
|
||||
|
@ -65,7 +88,7 @@ function initTerm() {
|
|||
}
|
||||
|
||||
function sendMessage(data: Message) {
|
||||
websocket.send(JSON.stringify(data))
|
||||
websocket.value.send(JSON.stringify(data))
|
||||
}
|
||||
|
||||
function wsOnMessage(msg: { data: string | Uint8Array }) {
|
||||
|
@ -82,13 +105,20 @@ onUnmounted(() => {
|
|||
window.removeEventListener('resize', fit)
|
||||
clearInterval(ping)
|
||||
term?.dispose()
|
||||
websocket.close()
|
||||
websocket.value?.close()
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ACard :title="$gettext('Terminal')">
|
||||
<AAlert
|
||||
v-if="lostConnection"
|
||||
class="mb-6"
|
||||
type="error"
|
||||
show-icon
|
||||
:message="$gettext('Connection lost, please refresh the page.')"
|
||||
/>
|
||||
<div
|
||||
id="terminal"
|
||||
class="console"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue