Browse Source

feat: 2fa via passkey

Jacky 9 months ago
parent
commit
0a6a7693a1

+ 156 - 0
api/user/2fa.go

@@ -0,0 +1,156 @@
+package user
+
+import (
+    "encoding/base64"
+    "fmt"
+    "github.com/0xJacky/Nginx-UI/api"
+    "github.com/0xJacky/Nginx-UI/internal/cache"
+    "github.com/0xJacky/Nginx-UI/internal/passkey"
+    "github.com/0xJacky/Nginx-UI/internal/user"
+    "github.com/0xJacky/Nginx-UI/model"
+    "github.com/0xJacky/Nginx-UI/query"
+    "github.com/gin-gonic/gin"
+    "github.com/go-webauthn/webauthn/webauthn"
+    "github.com/google/uuid"
+    "net/http"
+    "strings"
+    "time"
+)
+
+type Status2FA struct {
+    Enabled       bool `json:"enabled"`
+    OTPStatus     bool `json:"otp_status"`
+    PasskeyStatus bool `json:"passkey_status"`
+}
+
+func get2FAStatus(c *gin.Context) (status Status2FA) {
+    // when accessing the node from the main cluster, there is no user in the context
+    u, ok := c.Get("user")
+    if ok {
+        userPtr := u.(*model.User)
+        status.OTPStatus = userPtr.EnabledOTP()
+        status.PasskeyStatus = userPtr.EnabledPasskey() && passkey.Enabled()
+        status.Enabled = status.OTPStatus || status.PasskeyStatus
+    }
+    return
+}
+
+func Get2FAStatus(c *gin.Context) {
+    c.JSON(http.StatusOK, get2FAStatus(c))
+}
+
+func SecureSessionStatus(c *gin.Context) {
+    status2FA := get2FAStatus(c)
+    if !status2FA.Enabled {
+        c.JSON(http.StatusOK, gin.H{
+            "status": false,
+        })
+        return
+    }
+
+    ssid := c.GetHeader("X-Secure-Session-ID")
+    if ssid == "" {
+        ssid = c.Query("X-Secure-Session-ID")
+    }
+    if ssid == "" {
+        c.JSON(http.StatusOK, gin.H{
+            "status": false,
+        })
+        return
+    }
+
+    u := api.CurrentUser(c)
+
+    c.JSON(http.StatusOK, gin.H{
+        "status": user.VerifySecureSessionID(ssid, u.ID),
+    })
+}
+
+func Start2FASecureSessionByOTP(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 has not configured OTP as 2FA",
+        })
+        return
+    }
+
+    if json.OTP == "" && json.RecoveryCode == "" {
+        c.JSON(http.StatusBadRequest, LoginResponse{
+            Message: "The user has enabled OTP as 2FA",
+        })
+        return
+    }
+
+    if err := user.VerifyOTP(u, json.OTP, json.RecoveryCode); err != nil {
+        c.JSON(http.StatusBadRequest, LoginResponse{
+            Message: "Invalid OTP or recovery code",
+        })
+        return
+    }
+
+    sessionId := user.SetSecureSessionID(u.ID)
+
+    c.JSON(http.StatusOK, gin.H{
+        "session_id": sessionId,
+    })
+}
+
+func BeginStart2FASecureSessionByPasskey(c *gin.Context) {
+    if !passkey.Enabled() {
+        api.ErrHandler(c, fmt.Errorf("WebAuthn settings are not configured"))
+        return
+    }
+    webauthnInstance := passkey.GetInstance()
+    u := api.CurrentUser(c)
+    options, sessionData, err := webauthnInstance.BeginLogin(u)
+    if err != nil {
+        api.ErrHandler(c, err)
+        return
+    }
+    passkeySessionID := uuid.NewString()
+    cache.Set(passkeySessionID, sessionData, passkeyTimeout)
+    c.JSON(http.StatusOK, gin.H{
+        "session_id": passkeySessionID,
+        "options":    options,
+    })
+}
+
+func FinishStart2FASecureSessionByPasskey(c *gin.Context) {
+    if !passkey.Enabled() {
+        api.ErrHandler(c, fmt.Errorf("WebAuthn settings are not configured"))
+        return
+    }
+    passkeySessionID := c.GetHeader("X-Passkey-Session-ID")
+    sessionDataBytes, ok := cache.Get(passkeySessionID)
+    if !ok {
+        api.ErrHandler(c, fmt.Errorf("session not found"))
+        return
+    }
+    sessionData := sessionDataBytes.(*webauthn.SessionData)
+    webauthnInstance := passkey.GetInstance()
+    u := api.CurrentUser(c)
+    credential, err := webauthnInstance.FinishLogin(u, *sessionData, c.Request)
+    if err != nil {
+        api.ErrHandler(c, err)
+        return
+    }
+    rawID := strings.TrimRight(base64.StdEncoding.EncodeToString(credential.ID), "=")
+    p := query.Passkey
+    _, _ = p.Where(p.RawID.Eq(rawID)).Updates(&model.Passkey{
+        LastUsedAt: time.Now().Unix(),
+    })
+
+    sessionId := user.SetSecureSessionID(u.ID)
+
+    c.JSON(http.StatusOK, gin.H{
+        "session_id": sessionId,
+    })
+}

+ 3 - 1
api/user/auth.go

@@ -8,6 +8,7 @@ import (
 	"github.com/0xJacky/Nginx-UI/settings"
 	"github.com/gin-gonic/gin"
 	"github.com/pkg/errors"
+	"math/rand/v2"
 	"net/http"
 	"sync"
 	"time"
@@ -67,7 +68,8 @@ func Login(c *gin.Context) {
 
 	u, err := user.Login(json.Name, json.Password)
 	if err != nil {
-		// time.Sleep(5 * time.Second)
+		random := time.Duration(rand.Int() % 10)
+		time.Sleep(random * time.Second)
 		switch {
 		case errors.Is(err, user.ErrPasswordIncorrect):
 			c.JSON(http.StatusForbidden, LoginResponse{

+ 0 - 81
api/user/otp.go

@@ -8,8 +8,6 @@ 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/model"
 	"github.com/0xJacky/Nginx-UI/query"
 	"github.com/0xJacky/Nginx-UI/settings"
 	"github.com/gin-gonic/gin"
@@ -147,82 +145,3 @@ func ResetOTP(c *gin.Context) {
 		"message": "ok",
 	})
 }
-
-func OTPStatus(c *gin.Context) {
-	status := false
-	u, ok := c.Get("user")
-	if ok {
-		status = u.(*model.User).EnabledOTP()
-	}
-	c.JSON(http.StatusOK, gin.H{
-		"status": status,
-	})
-}
-
-func SecureSessionStatus(c *gin.Context) {
-	u, ok := c.Get("user")
-	if !ok || !u.(*model.User).EnabledOTP() {
-		c.JSON(http.StatusOK, gin.H{
-			"status": false,
-		})
-		return
-	}
-	ssid := c.GetHeader("X-Secure-Session-ID")
-	if ssid == "" {
-		ssid = c.Query("X-Secure-Session-ID")
-	}
-	if ssid == "" {
-		c.JSON(http.StatusOK, gin.H{
-			"status": false,
-		})
-		return
-	}
-
-	if user.VerifySecureSessionID(ssid, u.(*model.User).ID) {
-		c.JSON(http.StatusOK, gin.H{
-			"status": true,
-		})
-		return
-	}
-
-	c.JSON(http.StatusOK, gin.H{
-		"status": false,
-	})
-}
-
-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,
-	})
-}

+ 10 - 0
api/user/passkey.go

@@ -27,6 +27,12 @@ func buildCachePasskeyRegKey(id int) string {
 	return fmt.Sprintf("passkey-reg-%d", id)
 }
 
+func GetPasskeyConfigStatus(c *gin.Context) {
+	c.JSON(http.StatusOK, gin.H{
+		"status": passkey.Enabled(),
+	})
+}
+
 func BeginPasskeyRegistration(c *gin.Context) {
 	u := api.CurrentUser(c)
 
@@ -100,6 +106,10 @@ func BeginPasskeyLogin(c *gin.Context) {
 }
 
 func FinishPasskeyLogin(c *gin.Context) {
+	if !passkey.Enabled() {
+		api.ErrHandler(c, fmt.Errorf("WebAuthn settings are not configured"))
+		return
+	}
 	sessionId := c.GetHeader("X-Passkey-Session-ID")
 	sessionDataBytes, ok := cache.Get(sessionId)
 	if !ok {

+ 7 - 4
api/user/router.go

@@ -25,17 +25,20 @@ func InitManageUserRouter(r *gin.RouterGroup) {
 }
 
 func InitUserRouter(r *gin.RouterGroup) {
-	r.GET("/otp_status", OTPStatus)
+	r.GET("/2fa_status", Get2FAStatus)
+	r.GET("/2fa_secure_session/status", SecureSessionStatus)
+	r.POST("/2fa_secure_session/otp", Start2FASecureSessionByOTP)
+	r.GET("/2fa_secure_session/passkey", BeginStart2FASecureSessionByPasskey)
+	r.POST("/2fa_secure_session/passkey", FinishStart2FASecureSessionByPasskey)
+
 	r.GET("/otp_secret", GenerateTOTP)
 	r.POST("/otp_enroll", EnrollTOTP)
 	r.POST("/otp_reset", ResetOTP)
 
-	r.GET("/otp_secure_session_status", SecureSessionStatus)
-	r.POST("/otp_secure_session", StartSecure2FASession)
-
 	r.GET("/begin_passkey_register", BeginPasskeyRegistration)
 	r.POST("/finish_passkey_register", FinishPasskeyRegistration)
 
+	r.GET("/passkeys/config", GetPasskeyConfigStatus)
 	r.GET("/passkeys", GetPasskeyList)
 	r.POST("/passkeys/:id", UpdatePasskey)
 	r.DELETE("/passkeys/:id", DeletePasskey)

+ 2 - 0
app/components.d.ts

@@ -7,6 +7,8 @@ export {}
 /* prettier-ignore */
 declare module 'vue' {
   export interface GlobalComponents {
+    2FA2FAAuthorization: typeof import('./src/components/2FA/2FAAuthorization.vue')['default']
+    2FAOTPAuthorization: typeof import('./src/components/2FA/OTPAuthorization.vue')['default']
     AAlert: typeof import('ant-design-vue/es')['Alert']
     AAutoComplete: typeof import('ant-design-vue/es')['AutoComplete']
     AAvatar: typeof import('ant-design-vue/es')['Avatar']

+ 37 - 0
app/src/api/2fa.ts

@@ -0,0 +1,37 @@
+import type { AuthenticationResponseJSON } from '@simplewebauthn/types'
+import http from '@/lib/http'
+
+export interface TwoFAStatusResponse {
+  enabled: boolean
+  otp_status: boolean
+  passkey_status: boolean
+}
+
+const twoFA = {
+  status(): Promise<TwoFAStatusResponse> {
+    return http.get('/2fa_status')
+  },
+  start_secure_session_by_otp(passcode: string, recovery_code: string): Promise<{ session_id: string }> {
+    return http.post('/2fa_secure_session/otp', {
+      otp: passcode,
+      recovery_code,
+    })
+  },
+  secure_session_status(): Promise<{ status: boolean }> {
+    return http.get('/2fa_secure_session/status')
+  },
+  begin_start_secure_session_by_passkey() {
+    return http.get('/2fa_secure_session/passkey')
+  },
+  finish_start_secure_session_by_passkey(data: { session_id: string; options: AuthenticationResponseJSON }): Promise<{
+    session_id: string
+  }> {
+    return http.post('/2fa_secure_session/passkey', data.options, {
+      headers: {
+        'X-Passkey-Session-Id': data.session_id,
+      },
+    })
+  },
+}
+
+export default twoFA

+ 0 - 12
app/src/api/otp.ts

@@ -6,9 +6,6 @@ export interface OTPGenerateSecretResponse {
 }
 
 const otp = {
-  status(): Promise<{ status: boolean }> {
-    return http.get('/otp_status')
-  },
   generate_secret(): Promise<OTPGenerateSecretResponse> {
     return http.get('/otp_secret')
   },
@@ -18,15 +15,6 @@ 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,
-    })
-  },
-  secure_session_status() {
-    return http.get('/otp_secure_session_status')
-  },
 }
 
 export default otp

+ 2 - 2
app/src/api/passkey.ts

@@ -27,8 +27,8 @@ const passkey = {
   remove(passkeyId: number) {
     return http.delete(`/passkeys/${passkeyId}`)
   },
-  get_passkey_enabled() {
-    return http.get('/passkey_enabled')
+  get_passkey_config_status(): Promise<{ status: boolean }> {
+    return http.get('/passkey/config')
   },
 }
 

+ 45 - 3
app/src/components/OTP/OTPAuthorization.vue → app/src/components/2FA/2FAAuthorization.vue

@@ -1,12 +1,18 @@
 <script setup lang="ts">
+import { KeyOutlined } from '@ant-design/icons-vue'
+import { startAuthentication } from '@simplewebauthn/browser'
+import { message } from 'ant-design-vue'
 import OTPInput from '@/components/OTPInput/OTPInput.vue'
+import { $gettext } from '@/gettext'
+import twoFA from '@/api/2fa'
 
-const emit = defineEmits(['onSubmit'])
+const emit = defineEmits(['submitOTP', 'submitSecureSessionID'])
 
 const refOTP = ref()
 const useRecoveryCode = ref(false)
 const passcode = ref('')
 const recoveryCode = ref('')
+const passkeyLoading = ref(false)
 
 function clickUseRecoveryCode() {
   passcode.value = ''
@@ -19,7 +25,7 @@ function clickUseOTP() {
 }
 
 function onSubmit() {
-  emit('onSubmit', passcode.value, recoveryCode.value)
+  emit('submitOTP', passcode.value, recoveryCode.value)
 }
 
 function clearInput() {
@@ -29,12 +35,32 @@ function clearInput() {
 defineExpose({
   clearInput,
 })
+
+async function passkeyAuthenticate() {
+  passkeyLoading.value = true
+  try {
+    const begin = await twoFA.begin_start_secure_session_by_passkey()
+    const asseResp = await startAuthentication(begin.options.publicKey)
+
+    const r = await twoFA.finish_start_secure_session_by_passkey({
+      session_id: begin.session_id,
+      options: asseResp,
+    })
+
+    emit('submitSecureSessionID', r.session_id)
+  }
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  catch (e: any) {
+    message.error($gettext(e.message ?? 'Server error'))
+  }
+  passkeyLoading.value = false
+}
 </script>
 
 <template>
   <div>
     <div v-if="!useRecoveryCode">
-      <p>{{ $gettext('Please enter the 2FA code:') }}</p>
+      <p>{{ $gettext('Please enter the OTP code:') }}</p>
       <OTPInput
         ref="refOTP"
         v-model="passcode"
@@ -68,6 +94,22 @@ defineExpose({
         @click="clickUseOTP"
       >{{ $gettext('Use OTP') }}</a>
     </div>
+
+    <div class="flex flex-col justify-center">
+      <ADivider>
+        <div class="text-sm font-normal opacity-75">
+          {{ $gettext('Or') }}
+        </div>
+      </ADivider>
+
+      <AButton
+        :loading="passkeyLoading"
+        @click="passkeyAuthenticate"
+      >
+        <KeyOutlined />
+        {{ $gettext('Authenticate with a passkey') }}
+      </AButton>
+    </div>
   </div>
 </template>
 

+ 19 - 14
app/src/components/OTP/useOTPModal.ts → app/src/components/2FA/use2FAModal.ts

@@ -1,11 +1,11 @@
 import { createVNode, render } from 'vue'
 import { Modal, message } from 'ant-design-vue'
 import { useCookies } from '@vueuse/integrations/useCookies'
-import OTPAuthorization from '@/components/OTP/OTPAuthorization.vue'
-import otp from '@/api/otp'
+import OTPAuthorization from '@/components/2FA/2FAAuthorization.vue'
+import twoFA from '@/api/2fa'
 import { useUserStore } from '@/pinia'
 
-const useOTPModal = () => {
+const use2FAModal = () => {
   const refOTPAuthorization = ref<typeof OTPAuthorization>()
   const randomId = Math.random().toString(36).substring(2, 8)
   const { secureSessionId } = storeToRefs(useUserStore())
@@ -22,11 +22,11 @@ const useOTPModal = () => {
   }
 
   const open = async (): Promise<string> => {
-    const { status } = await otp.status()
-    const { status: secureSessionStatus } = await otp.secure_session_status()
+    const { enabled } = await twoFA.status()
+    const { status: secureSessionStatus } = await twoFA.secure_session_status()
 
     return new Promise((resolve, reject) => {
-      if (!status) {
+      if (!enabled) {
         resolve('')
 
         return
@@ -50,12 +50,16 @@ const useOTPModal = () => {
         container = null
       }
 
-      const verify = (passcode: string, recovery: string) => {
-        otp.start_secure_session(passcode, recovery).then(async r => {
-          cookies.set('secure_session_id', r.session_id, { maxAge: 60 * 3 })
-          close()
-          secureSessionId.value = r.session_id
-          resolve(r.session_id)
+      const setSessionId = (sessionId: string) => {
+        cookies.set('secure_session_id', sessionId, { maxAge: 60 * 3 })
+        close()
+        secureSessionId.value = sessionId
+        resolve(sessionId)
+      }
+
+      const verifyOTP = (passcode: string, recovery: string) => {
+        twoFA.start_secure_session_by_otp(passcode, recovery).then(async r => {
+          setSessionId(r.session_id)
         }).catch(async () => {
           refOTPAuthorization.value?.clearInput()
           await message.error($gettext('Invalid passcode or recovery code'))
@@ -80,7 +84,8 @@ const useOTPModal = () => {
           {
             ref: refOTPAuthorization,
             class: 'mt-3',
-            onOnSubmit: verify,
+            onSubmitOTP: verifyOTP,
+            onSubmitSecureSessionID: setSessionId,
           },
         ),
       })
@@ -92,4 +97,4 @@ const useOTPModal = () => {
   return { open }
 }
 
-export default useOTPModal
+export default use2FAModal

+ 2 - 2
app/src/lib/http/index.ts

@@ -7,7 +7,7 @@ import { useSettingsStore, useUserStore } from '@/pinia'
 import 'nprogress/nprogress.css'
 
 import router from '@/routes'
-import useOTPModal from '@/components/OTP/useOTPModal'
+import use2FAModal from '@/components/2FA/use2FAModal'
 
 const user = useUserStore()
 const settings = useSettingsStore()
@@ -61,7 +61,7 @@ instance.interceptors.response.use(
   async error => {
     NProgress.done()
 
-    const otpModal = useOTPModal()
+    const otpModal = use2FAModal()
     const cookies = useCookies(['nginx-ui-2fa'])
     switch (error.response.status) {
       case 401:

+ 2 - 2
app/src/views/config/components/Mkdir.vue

@@ -2,7 +2,7 @@
 
 import { message } from 'ant-design-vue'
 import config from '@/api/config'
-import useOTPModal from '@/components/OTP/useOTPModal'
+import use2FAModal from '@/components/2FA/use2FAModal'
 
 const emit = defineEmits(['created'])
 const visible = ref(false)
@@ -25,7 +25,7 @@ defineExpose({
 
 function ok() {
   refForm.value.validate().then(() => {
-    const otpModal = useOTPModal()
+    const otpModal = use2FAModal()
 
     otpModal.open().then(() => {
       config.mkdir(data.value.basePath, data.value.name).then(() => {

+ 2 - 2
app/src/views/config/components/Rename.vue

@@ -1,7 +1,7 @@
 <script setup lang="ts">
 import { message } from 'ant-design-vue'
 import config from '@/api/config'
-import useOTPModal from '@/components/OTP/useOTPModal'
+import use2FAModal from '@/components/2FA/use2FAModal'
 import NodeSelector from '@/components/NodeSelector/NodeSelector.vue'
 
 const emit = defineEmits(['renamed'])
@@ -33,7 +33,7 @@ function ok() {
   refForm.value.validate().then(() => {
     const { basePath, orig_name, new_name, sync_node_ids } = data.value
 
-    const otpModal = useOTPModal()
+    const otpModal = use2FAModal()
 
     otpModal.open().then(() => {
       config.rename(basePath, orig_name, new_name, sync_node_ids).then(() => {

+ 1 - 3
app/src/views/other/Login.vue

@@ -8,7 +8,7 @@ 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 OTPAuthorization from '@/components/OTP/OTPAuthorization.vue'
+import OTPAuthorization from '@/components/2FA/2FAAuthorization.vue'
 import gettext, { $gettext } from '@/gettext'
 
 const thisYear = new Date().getFullYear()
@@ -154,8 +154,6 @@ async function passkeyLogin() {
     const begin = await auth.begin_passkey_login()
     const asseResp = await startAuthentication(begin.options.publicKey)
 
-    console.log(asseResp)
-
     const r = await auth.finish_passkey_login({
       session_id: begin.session_id,
       options: asseResp,

+ 3 - 3
app/src/views/preference/Preference.vue

@@ -11,7 +11,7 @@ import type { Settings } from '@/views/preference/typedef'
 import LogrotateSettings from '@/views/preference/LogrotateSettings.vue'
 import { useSettingsStore } from '@/pinia'
 import AuthSettings from '@/views/preference/AuthSettings.vue'
-import useOTPModal from '@/components/OTP/useOTPModal'
+import use2FAModal from '@/components/2FA/use2FAModal'
 
 const data = ref<Settings>({
   server: {
@@ -68,7 +68,7 @@ async function save() {
   // fix type
   data.value.server.http_challenge_port = data.value.server.http_challenge_port.toString()
 
-  const otpModal = useOTPModal()
+  const otpModal = use2FAModal()
 
   otpModal.open().then(() => {
     settings.save(data.value).then(r => {
@@ -110,7 +110,7 @@ onMounted(() => {
 <template>
   <ACard :title="$gettext('Preference')">
     <div class="preference-container">
-      <ATabs v-model:activeKey="activeKey">
+      <ATabs v-model:active-key="activeKey">
         <ATabPane
           key="basic"
           :tab="$gettext('Basic')"

+ 3 - 2
app/src/views/preference/components/TOTP.vue

@@ -5,6 +5,7 @@ import { UseClipboard } from '@vueuse/components'
 import otp from '@/api/otp'
 import OTPInput from '@/components/OTPInput/OTPInput.vue'
 import { $gettext } from '@/gettext'
+import twoFA from '@/api/2fa'
 
 const status = ref(false)
 const enrolling = ref(false)
@@ -59,8 +60,8 @@ function enroll(code: string) {
 }
 
 function get2FAStatus() {
-  otp.status().then(r => {
-    status.value = r.status
+  twoFA.status().then(r => {
+    status.value = r.otp_status
   })
 }
 

+ 4 - 4
app/src/views/pty/Terminal.vue

@@ -4,8 +4,8 @@ import { Terminal } from '@xterm/xterm'
 import { FitAddon } from '@xterm/addon-fit'
 import _ from 'lodash'
 import ws from '@/lib/websocket'
-import useOTPModal from '@/components/OTP/useOTPModal'
-import otp from '@/api/otp'
+import use2FAModal from '@/components/2FA/use2FAModal'
+import twoFA from '@/api/2fa'
 
 let term: Terminal | null
 let ping: NodeJS.Timeout
@@ -15,9 +15,9 @@ const websocket = shallowRef()
 const lostConnection = ref(false)
 
 onMounted(() => {
-  otp.secure_session_status()
+  twoFA.secure_session_status()
 
-  const otpModal = useOTPModal()
+  const otpModal = use2FAModal()
 
   otpModal.open().then(secureSessionId => {
     websocket.value = ws(`/api/pty?X-Secure-Session-ID=${secureSessionId}`, false)

+ 1 - 1
internal/user/otp.go

@@ -43,7 +43,7 @@ func VerifyOTP(user *model.User, otp, recoveryCode string) (err error) {
 }
 
 func secureSessionIDCacheKey(sessionId string) string {
-	return fmt.Sprintf("otp_secure_session:_%s", sessionId)
+	return fmt.Sprintf("2fa_secure_session:_%s", sessionId)
 }
 
 func SetSecureSessionID(userId int) (sessionId string) {

+ 14 - 11
model/auth.go

@@ -3,17 +3,15 @@ package model
 import (
 	"github.com/go-webauthn/webauthn/webauthn"
 	"github.com/spf13/cast"
-	"gorm.io/gorm"
 )
 
 type User struct {
 	Model
 
-	Name       string `json:"name"`
-	Password   string `json:"-"`
-	Status     bool   `json:"status" gorm:"default:1"`
-	OTPSecret  []byte `json:"-" gorm:"type:blob"`
-	Enabled2FA bool   `json:"enabled_2fa" gorm:"-"`
+	Name      string `json:"name"`
+	Password  string `json:"-"`
+	Status    bool   `json:"status" gorm:"default:1"`
+	OTPSecret []byte `json:"-" gorm:"type:blob"`
 }
 
 type AuthToken struct {
@@ -26,15 +24,20 @@ func (u *User) TableName() string {
 	return "auths"
 }
 
-func (u *User) AfterFind(_ *gorm.DB) error {
-	u.Enabled2FA = u.EnabledOTP()
-	return nil
-}
-
 func (u *User) EnabledOTP() bool {
 	return len(u.OTPSecret) != 0
 }
 
+func (u *User) EnabledPasskey() bool {
+	var passkeys Passkey
+	db.Where("user_id", u.ID).First(&passkeys)
+	return passkeys.ID != 0
+}
+
+func (u *User) Enabled2FA() bool {
+	return u.EnabledOTP() || u.EnabledPasskey()
+}
+
 func (u *User) WebAuthnID() []byte {
 	return []byte(cast.ToString(u.ID))
 }

+ 71 - 71
router/routers.go

@@ -1,83 +1,83 @@
 package router
 
 import (
-    "github.com/0xJacky/Nginx-UI/api/analytic"
-    "github.com/0xJacky/Nginx-UI/api/certificate"
-    "github.com/0xJacky/Nginx-UI/api/cluster"
-    "github.com/0xJacky/Nginx-UI/api/config"
-    "github.com/0xJacky/Nginx-UI/api/nginx"
-    "github.com/0xJacky/Nginx-UI/api/notification"
-    "github.com/0xJacky/Nginx-UI/api/openai"
-    "github.com/0xJacky/Nginx-UI/api/settings"
-    "github.com/0xJacky/Nginx-UI/api/sites"
-    "github.com/0xJacky/Nginx-UI/api/streams"
-    "github.com/0xJacky/Nginx-UI/api/system"
-    "github.com/0xJacky/Nginx-UI/api/template"
-    "github.com/0xJacky/Nginx-UI/api/terminal"
-    "github.com/0xJacky/Nginx-UI/api/upstream"
-    "github.com/0xJacky/Nginx-UI/api/user"
-    "github.com/0xJacky/Nginx-UI/internal/middleware"
-    "github.com/gin-contrib/static"
-    "github.com/gin-gonic/gin"
-    "net/http"
+	"github.com/0xJacky/Nginx-UI/api/analytic"
+	"github.com/0xJacky/Nginx-UI/api/certificate"
+	"github.com/0xJacky/Nginx-UI/api/cluster"
+	"github.com/0xJacky/Nginx-UI/api/config"
+	"github.com/0xJacky/Nginx-UI/api/nginx"
+	"github.com/0xJacky/Nginx-UI/api/notification"
+	"github.com/0xJacky/Nginx-UI/api/openai"
+	"github.com/0xJacky/Nginx-UI/api/settings"
+	"github.com/0xJacky/Nginx-UI/api/sites"
+	"github.com/0xJacky/Nginx-UI/api/streams"
+	"github.com/0xJacky/Nginx-UI/api/system"
+	"github.com/0xJacky/Nginx-UI/api/template"
+	"github.com/0xJacky/Nginx-UI/api/terminal"
+	"github.com/0xJacky/Nginx-UI/api/upstream"
+	"github.com/0xJacky/Nginx-UI/api/user"
+	"github.com/0xJacky/Nginx-UI/internal/middleware"
+	"github.com/gin-contrib/static"
+	"github.com/gin-gonic/gin"
+	"net/http"
 )
 
 func InitRouter() *gin.Engine {
-    r := gin.New()
-    r.Use(
-        gin.Logger(),
-        middleware.Recovery(),
-        middleware.CacheJs(),
-        middleware.IPWhiteList(),
-        static.Serve("/", middleware.MustFs("")),
-    )
+	r := gin.New()
+	r.Use(
+		gin.Logger(),
+		middleware.Recovery(),
+		middleware.CacheJs(),
+		middleware.IPWhiteList(),
+		static.Serve("/", middleware.MustFs("")),
+	)
 
-    r.NoRoute(func(c *gin.Context) {
-        c.JSON(http.StatusNotFound, gin.H{
-            "message": "not found",
-        })
-    })
+	r.NoRoute(func(c *gin.Context) {
+		c.JSON(http.StatusNotFound, gin.H{
+			"message": "not found",
+		})
+	})
 
-    root := r.Group("/api")
-    {
-        system.InitPublicRouter(root)
-        user.InitAuthRouter(root)
+	root := r.Group("/api")
+	{
+		system.InitPublicRouter(root)
+		user.InitAuthRouter(root)
 
-        // Authorization required not websocket request
-        g := root.Group("/", middleware.AuthRequired(), middleware.Proxy())
-        {
-            user.InitUserRouter(g)
-            analytic.InitRouter(g)
-            user.InitManageUserRouter(g)
-            nginx.InitRouter(g)
-            sites.InitRouter(g)
-            streams.InitRouter(g)
-            config.InitRouter(g)
-            template.InitRouter(g)
-            certificate.InitCertificateRouter(g)
-            certificate.InitDNSCredentialRouter(g)
-            certificate.InitAcmeUserRouter(g)
-            system.InitPrivateRouter(g)
-            settings.InitRouter(g)
-            openai.InitRouter(g)
-            cluster.InitRouter(g)
-            notification.InitRouter(g)
-        }
+		// Authorization required and not websocket request
+		g := root.Group("/", middleware.AuthRequired(), middleware.Proxy())
+		{
+			user.InitUserRouter(g)
+			analytic.InitRouter(g)
+			user.InitManageUserRouter(g)
+			nginx.InitRouter(g)
+			sites.InitRouter(g)
+			streams.InitRouter(g)
+			config.InitRouter(g)
+			template.InitRouter(g)
+			certificate.InitCertificateRouter(g)
+			certificate.InitDNSCredentialRouter(g)
+			certificate.InitAcmeUserRouter(g)
+			system.InitPrivateRouter(g)
+			settings.InitRouter(g)
+			openai.InitRouter(g)
+			cluster.InitRouter(g)
+			notification.InitRouter(g)
+		}
 
-        // Authorization required and websocket request
-        w := root.Group("/", middleware.AuthRequired(), middleware.ProxyWs())
-        {
-            analytic.InitWebSocketRouter(w)
-            certificate.InitCertificateWebSocketRouter(w)
-            o := w.Group("", middleware.RequireSecureSession())
-            {
-                terminal.InitRouter(o)
-            }
-            nginx.InitNginxLogRouter(w)
-            upstream.InitRouter(w)
-            system.InitWebSocketRouter(w)
-        }
-    }
+		// Authorization required and websocket request
+		w := root.Group("/", middleware.AuthRequired(), middleware.ProxyWs())
+		{
+			analytic.InitWebSocketRouter(w)
+			certificate.InitCertificateWebSocketRouter(w)
+			o := w.Group("", middleware.RequireSecureSession())
+			{
+				terminal.InitRouter(o)
+			}
+			nginx.InitNginxLogRouter(w)
+			upstream.InitRouter(w)
+			system.InitWebSocketRouter(w)
+		}
+	}
 
-    return r
+	return r
 }