Browse Source

feat: save settings required 2fa if enabled otp

Jacky 9 months ago
parent
commit
11c733547f

+ 2 - 1
api/settings/router.go

@@ -1,13 +1,14 @@
 package settings
 
 import (
+	"github.com/0xJacky/Nginx-UI/internal/middleware"
 	"github.com/gin-gonic/gin"
 )
 
 func InitRouter(r *gin.RouterGroup) {
 	r.GET("settings/server/name", GetServerName)
 	r.GET("settings", GetSettings)
-	r.POST("settings", SaveSettings)
+	r.POST("settings", middleware.RequireSecureSession(), SaveSettings)
 
 	r.GET("settings/auth/banned_ips", GetBanLoginIP)
 	r.DELETE("settings/auth/banned_ip", RemoveBannedIP)

+ 216 - 193
api/user/otp.go

@@ -1,215 +1,238 @@
 package user
 
 import (
-	"bytes"
-	"crypto/sha1"
-	"encoding/base64"
-	"encoding/hex"
-	"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"
-	"github.com/pquerna/otp"
-	"github.com/pquerna/otp/totp"
-	"image/jpeg"
-	"net/http"
-	"strings"
+    "bytes"
+    "crypto/sha1"
+    "encoding/base64"
+    "encoding/hex"
+    "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"
+    "github.com/pquerna/otp"
+    "github.com/pquerna/otp/totp"
+    "image/jpeg"
+    "net/http"
+    "strings"
 )
 
 func GenerateTOTP(c *gin.Context) {
-	u := api.CurrentUser(c)
-
-	issuer := fmt.Sprintf("Nginx UI %s", settings.ServerSettings.Name)
-	issuer = strings.TrimSpace(issuer)
-
-	otpOpts := totp.GenerateOpts{
-		Issuer:      issuer,
-		AccountName: u.Name,
-		Period:      30, // seconds
-		Digits:      otp.DigitsSix,
-		Algorithm:   otp.AlgorithmSHA1,
-	}
-	otpKey, err := totp.Generate(otpOpts)
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
-	ciphertext, err := crypto.AesEncrypt([]byte(otpKey.Secret()))
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
-
-	qrCode, err := otpKey.Image(512, 512)
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
-
-	// Encode the image to a buffer
-	var buf []byte
-	buffer := bytes.NewBuffer(buf)
-	err = jpeg.Encode(buffer, qrCode, nil)
-	if err != nil {
-		fmt.Println("Error encoding image:", err)
-		return
-	}
-
-	// Convert the buffer to a base64 string
-	base64Str := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(buffer.Bytes())
-
-	c.JSON(http.StatusOK, gin.H{
-		"secret":  base64.StdEncoding.EncodeToString(ciphertext),
-		"qr_code": base64Str,
-	})
+    u := api.CurrentUser(c)
+
+    issuer := fmt.Sprintf("Nginx UI %s", settings.ServerSettings.Name)
+    issuer = strings.TrimSpace(issuer)
+
+    otpOpts := totp.GenerateOpts{
+        Issuer:      issuer,
+        AccountName: u.Name,
+        Period:      30, // seconds
+        Digits:      otp.DigitsSix,
+        Algorithm:   otp.AlgorithmSHA1,
+    }
+    otpKey, err := totp.Generate(otpOpts)
+    if err != nil {
+        api.ErrHandler(c, err)
+        return
+    }
+    ciphertext, err := crypto.AesEncrypt([]byte(otpKey.Secret()))
+    if err != nil {
+        api.ErrHandler(c, err)
+        return
+    }
+
+    qrCode, err := otpKey.Image(512, 512)
+    if err != nil {
+        api.ErrHandler(c, err)
+        return
+    }
+
+    // Encode the image to a buffer
+    var buf []byte
+    buffer := bytes.NewBuffer(buf)
+    err = jpeg.Encode(buffer, qrCode, nil)
+    if err != nil {
+        fmt.Println("Error encoding image:", err)
+        return
+    }
+
+    // Convert the buffer to a base64 string
+    base64Str := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(buffer.Bytes())
+
+    c.JSON(http.StatusOK, gin.H{
+        "secret":  base64.StdEncoding.EncodeToString(ciphertext),
+        "qr_code": base64Str,
+    })
 }
 
 func EnrollTOTP(c *gin.Context) {
-	cUser := api.CurrentUser(c)
-	if cUser.EnabledOTP() {
-		c.JSON(http.StatusBadRequest, gin.H{
-			"message": "User already enrolled",
-		})
-		return
-	}
-
-	if settings.ServerSettings.Demo {
-		c.JSON(http.StatusBadRequest, gin.H{
-			"message": "This feature is disabled in demo mode",
-		})
-		return
-	}
-
-	var json struct {
-		Secret   string `json:"secret" binding:"required"`
-		Passcode string `json:"passcode" binding:"required"`
-	}
-	if !api.BindAndValid(c, &json) {
-		return
-	}
-
-	secret, err := base64.StdEncoding.DecodeString(json.Secret)
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
-
-	decrypted, err := crypto.AesDecrypt(secret)
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
-
-	if ok := totp.Validate(json.Passcode, string(decrypted)); !ok {
-		c.JSON(http.StatusNotAcceptable, gin.H{
-			"message": "Invalid passcode",
-		})
-		return
-	}
-
-	ciphertext, err := crypto.AesEncrypt(decrypted)
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
-
-	u := query.Auth
-	_, err = u.Where(u.ID.Eq(cUser.ID)).Update(u.OTPSecret, ciphertext)
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
-
-	recoveryCode := sha1.Sum(ciphertext)
-
-	c.JSON(http.StatusOK, gin.H{
-		"message":       "ok",
-		"recovery_code": hex.EncodeToString(recoveryCode[:]),
-	})
+    cUser := api.CurrentUser(c)
+    if cUser.EnabledOTP() {
+        c.JSON(http.StatusBadRequest, gin.H{
+            "message": "User already enrolled",
+        })
+        return
+    }
+
+    if settings.ServerSettings.Demo {
+        c.JSON(http.StatusBadRequest, gin.H{
+            "message": "This feature is disabled in demo mode",
+        })
+        return
+    }
+
+    var json struct {
+        Secret   string `json:"secret" binding:"required"`
+        Passcode string `json:"passcode" binding:"required"`
+    }
+    if !api.BindAndValid(c, &json) {
+        return
+    }
+
+    secret, err := base64.StdEncoding.DecodeString(json.Secret)
+    if err != nil {
+        api.ErrHandler(c, err)
+        return
+    }
+
+    decrypted, err := crypto.AesDecrypt(secret)
+    if err != nil {
+        api.ErrHandler(c, err)
+        return
+    }
+
+    if ok := totp.Validate(json.Passcode, string(decrypted)); !ok {
+        c.JSON(http.StatusNotAcceptable, gin.H{
+            "message": "Invalid passcode",
+        })
+        return
+    }
+
+    ciphertext, err := crypto.AesEncrypt(decrypted)
+    if err != nil {
+        api.ErrHandler(c, err)
+        return
+    }
+
+    u := query.Auth
+    _, err = u.Where(u.ID.Eq(cUser.ID)).Update(u.OTPSecret, ciphertext)
+    if err != nil {
+        api.ErrHandler(c, err)
+        return
+    }
+
+    recoveryCode := sha1.Sum(ciphertext)
+
+    c.JSON(http.StatusOK, gin.H{
+        "message":       "ok",
+        "recovery_code": hex.EncodeToString(recoveryCode[:]),
+    })
 }
 
 func ResetOTP(c *gin.Context) {
-	var json struct {
-		RecoveryCode string `json:"recovery_code"`
-	}
-	if !api.BindAndValid(c, &json) {
-		return
-	}
-	recoverCode, err := hex.DecodeString(json.RecoveryCode)
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
-	cUser := api.CurrentUser(c)
-	k := sha1.Sum(cUser.OTPSecret)
-	if !bytes.Equal(k[:], recoverCode) {
-		c.JSON(http.StatusBadRequest, gin.H{
-			"message": "Invalid recovery code",
-		})
-		return
-	}
-
-	u := query.Auth
-	_, err = u.Where(u.ID.Eq(cUser.ID)).UpdateSimple(u.OTPSecret.Null())
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
-
-	c.JSON(http.StatusOK, gin.H{
-		"message": "ok",
-	})
+    var json struct {
+        RecoveryCode string `json:"recovery_code"`
+    }
+    if !api.BindAndValid(c, &json) {
+        return
+    }
+    recoverCode, err := hex.DecodeString(json.RecoveryCode)
+    if err != nil {
+        api.ErrHandler(c, err)
+        return
+    }
+    cUser := api.CurrentUser(c)
+    k := sha1.Sum(cUser.OTPSecret)
+    if !bytes.Equal(k[:], recoverCode) {
+        c.JSON(http.StatusBadRequest, gin.H{
+            "message": "Invalid recovery code",
+        })
+        return
+    }
+
+    u := query.Auth
+    _, err = u.Where(u.ID.Eq(cUser.ID)).UpdateSimple(u.OTPSecret.Null())
+    if err != nil {
+        api.ErrHandler(c, err)
+        return
+    }
+
+    c.JSON(http.StatusOK, gin.H{
+        "message": "ok",
+    })
 }
 
 func OTPStatus(c *gin.Context) {
-	c.JSON(http.StatusOK, gin.H{
-		"status": len(api.CurrentUser(c).OTPSecret) > 0,
-	})
+    c.JSON(http.StatusOK, gin.H{
+        "status": len(api.CurrentUser(c).OTPSecret) > 0,
+    })
 }
 
 func SecureSessionStatus(c *gin.Context) {
-	// if you can visit this endpoint, you are already in a secure session
-	c.JSON(http.StatusOK, gin.H{
-		"status": true,
-	})
+    cUser := api.CurrentUser(c)
+    if !cUser.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, cUser.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,
-	})
+    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,
+    })
 }

+ 1 - 3
api/user/router.go

@@ -1,7 +1,6 @@
 package user
 
 import (
-    "github.com/0xJacky/Nginx-UI/internal/middleware"
     "github.com/gin-gonic/gin"
 )
 
@@ -27,7 +26,6 @@ func InitUserRouter(r *gin.RouterGroup) {
 	r.POST("/otp_enroll", EnrollTOTP)
 	r.POST("/otp_reset", ResetOTP)
 
-    r.GET("/otp_secure_session_status",
-        middleware.RequireSecureSession(), SecureSessionStatus)
+    r.GET("/otp_secure_session_status", SecureSessionStatus)
 	r.POST("/otp_secure_session", StartSecure2FASession)
 }

+ 4 - 4
app/src/components/OTP/useOTPModal.ts

@@ -23,6 +23,7 @@ const useOTPModal = () => {
 
   const open = async (): Promise<string> => {
     const { status } = await otp.status()
+    const { status: secureSessionStatus } = await otp.secure_session_status()
 
     return new Promise((resolve, reject) => {
       if (!status) {
@@ -33,13 +34,12 @@ const useOTPModal = () => {
 
       const cookies = useCookies(['nginx-ui-2fa'])
       const ssid = cookies.get('secure_session_id')
-      if (ssid) {
+      if (ssid && secureSessionStatus) {
         resolve(ssid)
         secureSessionId.value = ssid
 
         return
       }
-
       injectStyles()
       let container: HTMLDivElement | null = document.createElement('div')
       document.body.appendChild(container)
@@ -51,11 +51,11 @@ const useOTPModal = () => {
       }
 
       const verify = (passcode: string, recovery: string) => {
-        otp.start_secure_session(passcode, recovery).then(r => {
+        otp.start_secure_session(passcode, recovery).then(async r => {
           cookies.set('secure_session_id', r.session_id, { maxAge: 60 * 3 })
-          resolve(r.session_id)
           close()
           secureSessionId.value = r.session_id
+          resolve(r.session_id)
         }).catch(async () => {
           refOTPAuthorization.value?.clearInput()
           await message.error($gettext('Invalid passcode or recovery code'))

+ 16 - 10
app/src/views/preference/Preference.vue

@@ -11,6 +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'
 
 const data = ref<Settings>({
   server: {
@@ -66,16 +67,21 @@ const refAuthSettings = ref()
 async function save() {
   // fix type
   data.value.server.http_challenge_port = data.value.server.http_challenge_port.toString()
-  settings.save(data.value).then(r => {
-    if (!settingsStore.is_remote)
-      server_name.value = r?.server?.name ?? ''
-    data.value = r
-    refAuthSettings.value?.getBannedIPs?.()
-    message.success($gettext('Save successfully'))
-    errors.value = {}
-  }).catch(e => {
-    errors.value = e.errors
-    message.error(e?.message ?? $gettext('Server error'))
+
+  const otpModal = useOTPModal()
+
+  otpModal.open().then(() => {
+    settings.save(data.value).then(r => {
+      if (!settingsStore.is_remote)
+        server_name.value = r?.server?.name ?? ''
+      data.value = r
+      refAuthSettings.value?.getBannedIPs?.()
+      message.success($gettext('Save successfully'))
+      errors.value = {}
+    }).catch(e => {
+      errors.value = e.errors
+      message.error(e?.message ?? $gettext('Server error'))
+    })
   })
 }