feat: 2FA authorization for web terminal

This commit is contained in:
Jacky 2024-07-23 20:35:32 +08:00
parent 802d05f692
commit 3a22861640
No known key found for this signature in database
GPG key ID: 215C21B10DF38B4D
15 changed files with 359 additions and 54 deletions

View file

@ -86,7 +86,7 @@ func Login(c *gin.Context) {
} }
// Check if the user enables 2FA // Check if the user enables 2FA
if len(u.OTPSecret) > 0 { if u.EnabledOTP() {
if json.OTP == "" && json.RecoveryCode == "" { if json.OTP == "" && json.RecoveryCode == "" {
c.JSON(http.StatusOK, LoginResponse{ c.JSON(http.StatusOK, LoginResponse{
Message: "The user has enabled 2FA", Message: "The user has enabled 2FA",

View file

@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"github.com/0xJacky/Nginx-UI/api" "github.com/0xJacky/Nginx-UI/api"
"github.com/0xJacky/Nginx-UI/internal/crypto" "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/query"
"github.com/0xJacky/Nginx-UI/settings" "github.com/0xJacky/Nginx-UI/settings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -67,8 +68,8 @@ func GenerateTOTP(c *gin.Context) {
} }
func EnrollTOTP(c *gin.Context) { func EnrollTOTP(c *gin.Context) {
user := api.CurrentUser(c) cUser := api.CurrentUser(c)
if len(user.OTPSecret) > 0 { if cUser.EnabledOTP() {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"message": "User already enrolled", "message": "User already enrolled",
}) })
@ -109,7 +110,7 @@ func EnrollTOTP(c *gin.Context) {
} }
u := query.Auth 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 { if err != nil {
api.ErrHandler(c, err) api.ErrHandler(c, err)
return return
@ -135,8 +136,8 @@ func ResetOTP(c *gin.Context) {
api.ErrHandler(c, err) api.ErrHandler(c, err)
return return
} }
user := api.CurrentUser(c) cUser := api.CurrentUser(c)
k := sha1.Sum(user.OTPSecret) k := sha1.Sum(cUser.OTPSecret)
if !bytes.Equal(k[:], recoverCode) { if !bytes.Equal(k[:], recoverCode) {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"message": "Invalid recovery code", "message": "Invalid recovery code",
@ -145,7 +146,7 @@ func ResetOTP(c *gin.Context) {
} }
u := query.Auth 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 { if err != nil {
api.ErrHandler(c, err) api.ErrHandler(c, err)
return return
@ -161,3 +162,40 @@ func OTPStatus(c *gin.Context) {
"status": len(api.CurrentUser(c).OTPSecret) > 0, "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,
})
}

View file

@ -23,4 +23,5 @@ func InitUserRouter(r *gin.RouterGroup) {
r.GET("/otp_secret", GenerateTOTP) r.GET("/otp_secret", GenerateTOTP)
r.POST("/otp_enroll", EnrollTOTP) r.POST("/otp_enroll", EnrollTOTP)
r.POST("/otp_reset", ResetOTP) r.POST("/otp_reset", ResetOTP)
r.POST("/otp_secure_session", StartSecure2FASession)
} }

3
app/components.d.ts vendored
View file

@ -78,8 +78,11 @@ declare module 'vue' {
NginxControlNginxControl: typeof import('./src/components/NginxControl/NginxControl.vue')['default'] NginxControlNginxControl: typeof import('./src/components/NginxControl/NginxControl.vue')['default']
NodeSelectorNodeSelector: typeof import('./src/components/NodeSelector/NodeSelector.vue')['default'] NodeSelectorNodeSelector: typeof import('./src/components/NodeSelector/NodeSelector.vue')['default']
NotificationNotification: typeof import('./src/components/Notification/Notification.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'] OTPInput: typeof import('./src/components/OTPInput.vue')['default']
OTPInputOTPInput: typeof import('./src/components/OTPInput/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'] PageHeaderPageHeader: typeof import('./src/components/PageHeader/PageHeader.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']

View file

@ -18,6 +18,12 @@ const otp = {
reset(recovery_code: string) { reset(recovery_code: string) {
return http.post('/otp_reset', { recovery_code }) 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 export default otp

View 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>

View 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

View file

@ -1,14 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { LockOutlined, UserOutlined } from '@ant-design/icons-vue' import { LockOutlined, UserOutlined } from '@ant-design/icons-vue'
import { Form, message } from 'ant-design-vue' import { Form, message } from 'ant-design-vue'
import OTPInput from '@/components/OTPInput/OTPInput.vue'
import { useUserStore } from '@/pinia' import { useUserStore } from '@/pinia'
import auth from '@/api/auth' import auth from '@/api/auth'
import install from '@/api/install' import install from '@/api/install'
import SetLanguage from '@/components/SetLanguage/SetLanguage.vue' import SetLanguage from '@/components/SetLanguage/SetLanguage.vue'
import SwitchAppearance from '@/components/SwitchAppearance/SwitchAppearance.vue' import SwitchAppearance from '@/components/SwitchAppearance/SwitchAppearance.vue'
import gettext, { $gettext } from '@/gettext' import gettext, { $gettext } from '@/gettext'
import OTPAuthorization from '@/components/OTP/OTPAuthorization.vue'
const thisYear = new Date().getFullYear() const thisYear = new Date().getFullYear()
@ -24,7 +23,6 @@ const loading = ref(false)
const enabled2FA = ref(false) const enabled2FA = ref(false)
const refOTP = ref() const refOTP = ref()
const passcode = ref('') const passcode = ref('')
const useRecoveryCode = ref(false)
const recoveryCode = ref('') const recoveryCode = ref('')
const modelRef = reactive({ const modelRef = reactive({
@ -135,9 +133,13 @@ if (route.query?.code !== undefined && route.query?.state !== undefined) {
loading.value = false loading.value = false
} }
function clickUseRecoveryCode() { function handleOTPSubmit(code: string, recovery: string) {
passcode.value = '' passcode.value = code
useRecoveryCode.value = true recoveryCode.value = recovery
nextTick(() => {
onSubmit()
})
} }
</script> </script>
@ -173,38 +175,10 @@ function clickUseRecoveryCode() {
</AFormItem> </AFormItem>
</template> </template>
<div v-else> <div v-else>
<div v-if="!useRecoveryCode"> <OTPAuthorization
<p>{{ $gettext('Please enter the 2FA code:') }}</p>
<OTPInput
ref="refOTP" ref="refOTP"
v-model="passcode" @on-submit="handleOTPSubmit"
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>
</div> </div>
<AFormItem v-if="!enabled2FA"> <AFormItem v-if="!enabled2FA">

View file

@ -2,20 +2,43 @@
import '@xterm/xterm/css/xterm.css' import '@xterm/xterm/css/xterm.css'
import { Terminal } from '@xterm/xterm' import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit' import { FitAddon } from '@xterm/addon-fit'
import { onMounted, onUnmounted } from 'vue'
import _ from 'lodash' import _ from 'lodash'
import ws from '@/lib/websocket' import ws from '@/lib/websocket'
import useOTPModal from '@/components/OTP/useOTPModal'
let term: Terminal | null let term: Terminal | null
let ping: NodeJS.Timeout let ping: NodeJS.Timeout
const websocket = ws('/api/pty') const router = useRouter()
const websocket = shallowRef()
const lostConnection = ref(false)
onMounted(() => { onMounted(() => {
initTerm() const otpModal = useOTPModal()
websocket.onmessage = wsOnMessage otpModal.open({
websocket.onopen = wsOnOpen 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 { interface Message {
@ -65,7 +88,7 @@ function initTerm() {
} }
function sendMessage(data: Message) { function sendMessage(data: Message) {
websocket.send(JSON.stringify(data)) websocket.value.send(JSON.stringify(data))
} }
function wsOnMessage(msg: { data: string | Uint8Array }) { function wsOnMessage(msg: { data: string | Uint8Array }) {
@ -82,13 +105,20 @@ onUnmounted(() => {
window.removeEventListener('resize', fit) window.removeEventListener('resize', fit)
clearInterval(ping) clearInterval(ping)
term?.dispose() term?.dispose()
websocket.close() websocket.value?.close()
}) })
</script> </script>
<template> <template>
<ACard :title="$gettext('Terminal')"> <ACard :title="$gettext('Terminal')">
<AAlert
v-if="lostConnection"
class="mb-6"
type="error"
show-icon
:message="$gettext('Connection lost, please refresh the page.')"
/>
<div <div
id="terminal" id="terminal"
class="console" class="console"

31
internal/cache/cache.go vendored Normal file
View file

@ -0,0 +1,31 @@
package cache
import (
"github.com/0xJacky/Nginx-UI/internal/logger"
"github.com/dgraph-io/ristretto"
"time"
)
var cache *ristretto.Cache
func Init() {
var err error
cache, err = ristretto.NewCache(&ristretto.Config{
NumCounters: 1e7, // number of keys to track frequency of (10M).
MaxCost: 1 << 30, // maximum cost of cache (1GB).
BufferItems: 64, // number of keys per Get buffer.
})
if err != nil {
logger.Fatal("initializing local cache err", err)
}
}
func Set(key interface{}, value interface{}, ttl time.Duration) {
cache.SetWithTTL(key, value, 0, ttl)
cache.Wait()
}
func Get(key interface{}) (value interface{}, ok bool) {
return cache.Get(key)
}

View file

@ -4,6 +4,7 @@ import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"github.com/0xJacky/Nginx-UI/internal/analytic" "github.com/0xJacky/Nginx-UI/internal/analytic"
"github.com/0xJacky/Nginx-UI/internal/cache"
"github.com/0xJacky/Nginx-UI/internal/cert" "github.com/0xJacky/Nginx-UI/internal/cert"
"github.com/0xJacky/Nginx-UI/internal/cluster" "github.com/0xJacky/Nginx-UI/internal/cluster"
"github.com/0xJacky/Nginx-UI/internal/cron" "github.com/0xJacky/Nginx-UI/internal/cron"
@ -26,6 +27,7 @@ func Boot() {
InitNodeSecret, InitNodeSecret,
InitCryptoSecret, InitCryptoSecret,
validation.Init, validation.Init,
cache.Init,
} }
syncs := []func(){ syncs := []func(){

View file

@ -4,10 +4,14 @@ import (
"bytes" "bytes"
"crypto/sha1" "crypto/sha1"
"encoding/hex" "encoding/hex"
"fmt"
"github.com/0xJacky/Nginx-UI/internal/cache"
"github.com/0xJacky/Nginx-UI/internal/crypto" "github.com/0xJacky/Nginx-UI/internal/crypto"
"github.com/0xJacky/Nginx-UI/model" "github.com/0xJacky/Nginx-UI/model"
"github.com/google/uuid"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/pquerna/otp/totp" "github.com/pquerna/otp/totp"
"time"
) )
var ( var (
@ -37,3 +41,23 @@ func VerifyOTP(user *model.Auth, otp, recoveryCode string) (err error) {
} }
return return
} }
func secureSessionIDCacheKey(sessionId string) string {
return fmt.Sprintf("otp_secure_session:_%s", sessionId)
}
func SetSecureSessionID(userId int) (sessionId string) {
sessionId = uuid.NewString()
cache.Set(secureSessionIDCacheKey(sessionId), userId, 5*time.Minute)
return
}
func VerifySecureSessionID(sessionId string, userId int) bool {
if v, ok := cache.Get(secureSessionIDCacheKey(sessionId)); ok {
if v.(int) == userId {
return true
}
}
return false
}

View file

@ -14,3 +14,7 @@ type AuthToken struct {
Token string `json:"token"` Token string `json:"token"`
ExpiredAt int64 `json:"expired_at" gorm:"default:0"` ExpiredAt int64 `json:"expired_at" gorm:"default:0"`
} }
func (u *Auth) EnabledOTP() bool {
return len(u.OTPSecret) != 0
}

View file

@ -5,6 +5,7 @@ import (
"github.com/0xJacky/Nginx-UI/app" "github.com/0xJacky/Nginx-UI/app"
"github.com/0xJacky/Nginx-UI/internal/logger" "github.com/0xJacky/Nginx-UI/internal/logger"
"github.com/0xJacky/Nginx-UI/internal/user" "github.com/0xJacky/Nginx-UI/internal/user"
"github.com/0xJacky/Nginx-UI/model"
"github.com/0xJacky/Nginx-UI/settings" "github.com/0xJacky/Nginx-UI/settings"
"github.com/gin-contrib/static" "github.com/gin-contrib/static"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -74,6 +75,41 @@ func authRequired() gin.HandlerFunc {
} }
} }
func required2FA() gin.HandlerFunc {
return func(c *gin.Context) {
u, ok := c.Get("user")
if !ok {
c.Next()
return
}
cUser := u.(*model.Auth)
if !cUser.EnabledOTP() {
c.Next()
return
}
ssid := c.GetHeader("X-Secure-Session-ID")
if ssid == "" {
ssid = c.Query("X-Secure-Session-ID")
}
if ssid == "" {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"message": "Secure Session ID is empty",
})
return
}
if user.VerifySecureSessionID(ssid, cUser.ID) {
c.Next()
return
}
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"message": "Secure Session ID is invalid",
})
return
}
}
type serverFileSystemType struct { type serverFileSystemType struct {
http.FileSystem http.FileSystem
} }

View file

@ -69,7 +69,10 @@ func InitRouter() *gin.Engine {
{ {
analytic.InitWebSocketRouter(w) analytic.InitWebSocketRouter(w)
certificate.InitCertificateWebSocketRouter(w) certificate.InitCertificateWebSocketRouter(w)
terminal.InitRouter(w) o := w.Group("", required2FA())
{
terminal.InitRouter(o)
}
nginx.InitNginxLogRouter(w) nginx.InitNginxLogRouter(w)
upstream.InitRouter(w) upstream.InitRouter(w)
system.InitWebSocketRouter(w) system.InitWebSocketRouter(w)