diff --git a/api/user/auth.go b/api/user/auth.go index 26ff86cf..5f750c08 100644 --- a/api/user/auth.go +++ b/api/user/auth.go @@ -86,7 +86,7 @@ func Login(c *gin.Context) { } // Check if the user enables 2FA - if len(u.OTPSecret) > 0 { + if u.EnabledOTP() { if json.OTP == "" && json.RecoveryCode == "" { c.JSON(http.StatusOK, LoginResponse{ Message: "The user has enabled 2FA", diff --git a/api/user/otp.go b/api/user/otp.go index 13bcc3e8..d6afedd4 100644 --- a/api/user/otp.go +++ b/api/user/otp.go @@ -8,6 +8,7 @@ import ( "fmt" "github.com/0xJacky/Nginx-UI/api" "github.com/0xJacky/Nginx-UI/internal/crypto" + "github.com/0xJacky/Nginx-UI/internal/user" "github.com/0xJacky/Nginx-UI/query" "github.com/0xJacky/Nginx-UI/settings" "github.com/gin-gonic/gin" @@ -67,8 +68,8 @@ func GenerateTOTP(c *gin.Context) { } func EnrollTOTP(c *gin.Context) { - user := api.CurrentUser(c) - if len(user.OTPSecret) > 0 { + cUser := api.CurrentUser(c) + if cUser.EnabledOTP() { c.JSON(http.StatusBadRequest, gin.H{ "message": "User already enrolled", }) @@ -109,7 +110,7 @@ func EnrollTOTP(c *gin.Context) { } u := query.Auth - _, err = u.Where(u.ID.Eq(user.ID)).Update(u.OTPSecret, ciphertext) + _, err = u.Where(u.ID.Eq(cUser.ID)).Update(u.OTPSecret, ciphertext) if err != nil { api.ErrHandler(c, err) return @@ -135,8 +136,8 @@ func ResetOTP(c *gin.Context) { api.ErrHandler(c, err) return } - user := api.CurrentUser(c) - k := sha1.Sum(user.OTPSecret) + cUser := api.CurrentUser(c) + k := sha1.Sum(cUser.OTPSecret) if !bytes.Equal(k[:], recoverCode) { c.JSON(http.StatusBadRequest, gin.H{ "message": "Invalid recovery code", @@ -145,7 +146,7 @@ func ResetOTP(c *gin.Context) { } u := query.Auth - _, err = u.Where(u.ID.Eq(user.ID)).UpdateSimple(u.OTPSecret.Null()) + _, err = u.Where(u.ID.Eq(cUser.ID)).UpdateSimple(u.OTPSecret.Null()) if err != nil { api.ErrHandler(c, err) return @@ -161,3 +162,40 @@ func OTPStatus(c *gin.Context) { "status": len(api.CurrentUser(c).OTPSecret) > 0, }) } + +func StartSecure2FASession(c *gin.Context) { + var json struct { + OTP string `json:"otp"` + RecoveryCode string `json:"recovery_code"` + } + if !api.BindAndValid(c, &json) { + return + } + u := api.CurrentUser(c) + if !u.EnabledOTP() { + c.JSON(http.StatusBadRequest, gin.H{ + "message": "User not configured with 2FA", + }) + return + } + + if json.OTP == "" && json.RecoveryCode == "" { + c.JSON(http.StatusBadRequest, LoginResponse{ + Message: "The user has enabled 2FA", + }) + return + } + + if err := user.VerifyOTP(u, json.OTP, json.RecoveryCode); err != nil { + c.JSON(http.StatusBadRequest, LoginResponse{ + Message: "Invalid 2FA or recovery code", + }) + return + } + + sessionId := user.SetSecureSessionID(u.ID) + + c.JSON(http.StatusOK, gin.H{ + "session_id": sessionId, + }) +} diff --git a/api/user/router.go b/api/user/router.go index 565985dd..f4b8c354 100644 --- a/api/user/router.go +++ b/api/user/router.go @@ -22,5 +22,6 @@ func InitUserRouter(r *gin.RouterGroup) { r.GET("/otp_status", OTPStatus) r.GET("/otp_secret", GenerateTOTP) r.POST("/otp_enroll", EnrollTOTP) - r.POST("/otp_reset", ResetOTP) + r.POST("/otp_reset", ResetOTP) + r.POST("/otp_secure_session", StartSecure2FASession) } diff --git a/app/components.d.ts b/app/components.d.ts index 507cb693..a4430ce2 100644 --- a/app/components.d.ts +++ b/app/components.d.ts @@ -78,8 +78,11 @@ declare module 'vue' { NginxControlNginxControl: typeof import('./src/components/NginxControl/NginxControl.vue')['default'] NodeSelectorNodeSelector: typeof import('./src/components/NodeSelector/NodeSelector.vue')['default'] NotificationNotification: typeof import('./src/components/Notification/Notification.vue')['default'] + OTP: typeof import('./src/components/OTP.vue')['default'] OTPInput: typeof import('./src/components/OTPInput.vue')['default'] OTPInputOTPInput: typeof import('./src/components/OTPInput/OTPInput.vue')['default'] + OTPOTPAuthorization: typeof import('./src/components/OTP/OTPAuthorization.vue')['default'] + OTPOTPAuthorizationModal: typeof import('./src/components/OTP/OTPAuthorizationModal.vue')['default'] PageHeaderPageHeader: typeof import('./src/components/PageHeader/PageHeader.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] diff --git a/app/src/api/otp.ts b/app/src/api/otp.ts index ba8f0180..aacd0289 100644 --- a/app/src/api/otp.ts +++ b/app/src/api/otp.ts @@ -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 diff --git a/app/src/components/OTP/OTPAuthorization.vue b/app/src/components/OTP/OTPAuthorization.vue new file mode 100644 index 00000000..840ebf99 --- /dev/null +++ b/app/src/components/OTP/OTPAuthorization.vue @@ -0,0 +1,78 @@ + + + + + + {{ $gettext('Please enter the 2FA code:') }} + + + + {{ $gettext('Input the recovery code:') }} + + + + {{ $gettext('Recovery') }} + + + + + + {{ $gettext('Use recovery code') }} + {{ $gettext('Use OTP') }} + + + + + diff --git a/app/src/components/OTP/useOTPModal.ts b/app/src/components/OTP/useOTPModal.ts new file mode 100644 index 00000000..6e2880c4 --- /dev/null +++ b/app/src/components/OTP/useOTPModal.ts @@ -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() + 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 diff --git a/app/src/views/other/Login.vue b/app/src/views/other/Login.vue index 02022793..b2e3a2ca 100644 --- a/app/src/views/other/Login.vue +++ b/app/src/views/other/Login.vue @@ -1,14 +1,13 @@ @@ -173,38 +175,10 @@ function clickUseRecoveryCode() { - - {{ $gettext('Please enter the 2FA code:') }} - - - - {{ $gettext('Use recovery code') }} - - - - - {{ $gettext('Input the recovery code:') }} - - - - {{ $gettext('Recovery') }} - - - + diff --git a/app/src/views/pty/Terminal.vue b/app/src/views/pty/Terminal.vue index 4727dd71..fca17276 100644 --- a/app/src/views/pty/Terminal.vue +++ b/app/src/views/pty/Terminal.vue @@ -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() }) +
{{ $gettext('Please enter the 2FA code:') }}
{{ $gettext('Input the recovery code:') }}