Browse Source

Merge pull request #447 from 0xJacky/feat/2fa

[Feature] Two Factor Authorization
Jacky 11 months ago
parent
commit
08ed0693ca
57 changed files with 2769 additions and 446 deletions
  1. 0 1
      .idea/vcs.xml
  2. 5 0
      api/api.go
  3. 31 10
      api/user/auth.go
  4. 1 1
      api/user/casdoor.go
  5. 208 0
      api/user/otp.go
  6. 18 10
      api/user/router.go
  7. 8 0
      app.example.ini
  8. 5 0
      app/components.d.ts
  9. 3 0
      app/package.json
  10. 93 0
      app/pnpm-lock.yaml
  11. 4 3
      app/src/api/auth.ts
  12. 29 0
      app/src/api/otp.ts
  13. 78 0
      app/src/components/OTP/OTPAuthorization.vue
  14. 92 0
      app/src/components/OTP/useOTPModal.ts
  15. 72 0
      app/src/components/OTPInput/OTPInput.vue
  16. 1 1
      app/src/language/LINGUAS
  17. 139 34
      app/src/language/en/app.po
  18. 140 34
      app/src/language/es/app.po
  19. 140 34
      app/src/language/fr_FR/app.po
  20. 140 34
      app/src/language/ko_KR/app.po
  21. 132 36
      app/src/language/messages.pot
  22. 140 34
      app/src/language/ru_RU/app.po
  23. 140 34
      app/src/language/vi_VN/app.po
  24. BIN
      app/src/language/zh_CN/app.mo
  25. 138 34
      app/src/language/zh_CN/app.po
  26. 140 34
      app/src/language/zh_TW/app.po
  27. 60 27
      app/src/views/other/Login.vue
  28. 3 0
      app/src/views/preference/AuthSettings.vue
  29. 176 0
      app/src/views/preference/components/TOTP.vue
  30. 38 8
      app/src/views/pty/Terminal.vue
  31. 2 1
      docs/.vitepress/config/en.ts
  32. 2 1
      docs/.vitepress/config/zh_CN.ts
  33. 2 1
      docs/.vitepress/config/zh_TW.ts
  34. 7 0
      docs/guide/config-crypto.md
  35. 3 0
      docs/index.md
  36. 6 0
      docs/zh_CN/guide/config-crypto.md
  37. 3 1
      docs/zh_CN/index.md
  38. 6 0
      docs/zh_TW/guide/config-crypto.md
  39. 3 1
      docs/zh_TW/index.md
  40. 17 12
      go.mod
  41. 42 10
      go.sum
  42. 31 0
      internal/cache/cache.go
  43. 14 2
      internal/cron/cron.go
  44. 59 0
      internal/crypto/aes.go
  45. 76 0
      internal/crypto/aes_test.go
  46. 27 21
      internal/kernal/boot.go
  47. 63 0
      internal/user/otp.go
  48. 94 16
      internal/user/user.go
  49. 12 5
      model/auth.go
  50. 11 3
      query/auth_tokens.gen.go
  51. 5 1
      query/auths.gen.go
  52. 40 1
      router/middleware.go
  53. 5 1
      router/routers.go
  54. 1 0
      settings/cluster_test.go
  55. 14 0
      settings/crypto.go
  56. 48 0
      settings/crypto_test.go
  57. 2 0
      settings/settings.go

+ 0 - 1
.idea/vcs.xml

@@ -2,6 +2,5 @@
 <project version="4">
   <component name="VcsDirectoryMappings">
     <mapping directory="" vcs="Git" />
-    <mapping directory="$PROJECT_DIR$/docs" vcs="Git" />
   </component>
 </project>

+ 5 - 0
api/api.go

@@ -3,6 +3,7 @@ package api
 import (
 	"errors"
 	"github.com/0xJacky/Nginx-UI/internal/logger"
+	"github.com/0xJacky/Nginx-UI/model"
 	"github.com/gin-gonic/gin"
 	"github.com/go-playground/validator/v10"
 	"net/http"
@@ -11,6 +12,10 @@ import (
 	"strings"
 )
 
+func CurrentUser(c *gin.Context) *model.Auth {
+	return c.MustGet("user").(*model.Auth)
+}
+
 func ErrHandler(c *gin.Context, err error) {
 	logger.GetLogger().Errorln(err)
 	c.JSON(http.StatusInternalServerError, gin.H{

+ 31 - 10
api/user/auth.go

@@ -16,14 +16,19 @@ import (
 var mutex = &sync.Mutex{}
 
 type LoginUser struct {
-	Name     string `json:"name" binding:"required,max=255"`
-	Password string `json:"password" binding:"required,max=255"`
+	Name         string `json:"name" binding:"required,max=255"`
+	Password     string `json:"password" binding:"required,max=255"`
+	OTP          string `json:"otp"`
+	RecoveryCode string `json:"recovery_code"`
 }
 
 const (
 	ErrPasswordIncorrect = 4031
 	ErrMaxAttempts       = 4291
 	ErrUserBanned        = 4033
+	Enabled2FA           = 199
+	Error2FACode         = 4034
+	LoginSuccess         = 200
 )
 
 type LoginResponse struct {
@@ -80,11 +85,32 @@ func Login(c *gin.Context) {
 		return
 	}
 
+	// Check if the user enables 2FA
+	if u.EnabledOTP() {
+		if json.OTP == "" && json.RecoveryCode == "" {
+			c.JSON(http.StatusOK, LoginResponse{
+				Message: "The user has enabled 2FA",
+				Code:    Enabled2FA,
+			})
+			user.BanIP(clientIP)
+			return
+		}
+
+		if err = user.VerifyOTP(u, json.OTP, json.RecoveryCode); err != nil {
+			c.JSON(http.StatusForbidden, LoginResponse{
+				Message: "Invalid 2FA or recovery code",
+				Code:    Error2FACode,
+			})
+			user.BanIP(clientIP)
+			return
+		}
+	}
+
 	// login success, clear banned record
 	_, _ = b.Where(b.IP.Eq(clientIP)).Delete()
 
 	logger.Info("[User Login]", u.Name)
-	token, err := user.GenerateJWT(u.Name)
+	token, err := user.GenerateJWT(u)
 	if err != nil {
 		c.JSON(http.StatusInternalServerError, LoginResponse{
 			Message: err.Error(),
@@ -93,6 +119,7 @@ func Login(c *gin.Context) {
 	}
 
 	c.JSON(http.StatusOK, LoginResponse{
+		Code:    LoginSuccess,
 		Message: "ok",
 		Token:   token,
 	})
@@ -101,13 +128,7 @@ func Login(c *gin.Context) {
 func Logout(c *gin.Context) {
 	token := c.GetHeader("Authorization")
 	if token != "" {
-		err := user.DeleteToken(token)
-		if err != nil {
-			c.JSON(http.StatusInternalServerError, gin.H{
-				"message": err.Error(),
-			})
-			return
-		}
+		user.DeleteToken(token)
 	}
 	c.JSON(http.StatusNoContent, nil)
 }

+ 1 - 1
api/user/casdoor.go

@@ -65,7 +65,7 @@ func CasdoorCallback(c *gin.Context) {
 		return
 	}
 
-	userToken, err := user.GenerateJWT(u.Name)
+	userToken, err := user.GenerateJWT(u)
 	if err != nil {
 		api.ErrHandler(c, err)
 		return

+ 208 - 0
api/user/otp.go

@@ -0,0 +1,208 @@
+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"
+)
+
+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,
+	})
+}
+
+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[:]),
+	})
+}
+
+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",
+	})
+}
+
+func OTPStatus(c *gin.Context) {
+	c.JSON(http.StatusOK, gin.H{
+		"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,
+	})
+}

+ 18 - 10
api/user/router.go

@@ -2,18 +2,26 @@ package user
 
 import "github.com/gin-gonic/gin"
 
-func InitAuthRouter(r *gin.RouterGroup)  {
-    r.POST("/login", Login)
-    r.DELETE("/logout", Logout)
+func InitAuthRouter(r *gin.RouterGroup) {
+	r.POST("/login", Login)
+	r.DELETE("/logout", Logout)
 
-    r.GET("/casdoor_uri", GetCasdoorUri)
-    r.POST("/casdoor_callback", CasdoorCallback)
+	r.GET("/casdoor_uri", GetCasdoorUri)
+	r.POST("/casdoor_callback", CasdoorCallback)
 }
 
 func InitManageUserRouter(r *gin.RouterGroup) {
-    r.GET("users", GetUsers)
-    r.GET("user/:id", GetUser)
-    r.POST("user", AddUser)
-    r.POST("user/:id", EditUser)
-    r.DELETE("user/:id", DeleteUser)
+	r.GET("users", GetUsers)
+	r.GET("user/:id", GetUser)
+	r.POST("user", AddUser)
+	r.POST("user/:id", EditUser)
+	r.DELETE("user/:id", DeleteUser)
+}
+
+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_secure_session", StartSecure2FASession)
 }

+ 8 - 0
app.example.ini

@@ -51,3 +51,11 @@ Interval = 1440
 Node = http://10.0.0.1:9000?name=node1&node_secret=my-node-secret&enabled=true
 Node = http://10.0.0.2:9000?name=node2&node_secret=my-node-secret&enabled=true
 Node = http://10.0.0.3?name=node3&node_secret=my-node-secret&enabled=true
+
+[auth]
+IPWhiteList         = 
+BanThresholdMinutes = 10
+MaxAttempts         = 10
+
+[crypto]
+Secret = secret2

+ 5 - 0
app/components.d.ts

@@ -78,6 +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']

+ 3 - 0
app/package.json

@@ -16,6 +16,7 @@
     "@vue/reactivity": "^3.4.33",
     "@vue/shared": "^3.4.33",
     "@vueuse/core": "^10.11.0",
+    "@vueuse/integrations": "^10.11.0",
     "@xterm/addon-attach": "^0.11.0",
     "@xterm/addon-fit": "^0.10.0",
     "@xterm/xterm": "^5.5.0",
@@ -31,6 +32,7 @@
     "pinia-plugin-persistedstate": "^3.2.1",
     "reconnecting-websocket": "^4.4.0",
     "sortablejs": "^1.15.2",
+    "universal-cookie": "^7",
     "vite-plugin-build-id": "^0.2.9",
     "vue": "^3.4.33",
     "vue-github-button": "github:0xJacky/vue-github-button",
@@ -38,6 +40,7 @@
     "vue3-ace-editor": "2.2.4",
     "vue3-apexcharts": "1.4.4",
     "vue3-gettext": "3.0.0-beta.4",
+    "vue3-otp-input": "^0.5.21",
     "vuedraggable": "^4.1.0"
   },
   "devDependencies": {

+ 93 - 0
app/pnpm-lock.yaml

@@ -23,6 +23,9 @@ importers:
       '@vueuse/core':
         specifier: ^10.11.0
         version: 10.11.0(vue@3.4.33(typescript@5.3.3))
+      '@vueuse/integrations':
+        specifier: ^10.11.0
+        version: 10.11.0(async-validator@4.2.5)(axios@1.7.2)(nprogress@0.2.0)(sortablejs@1.15.2)(universal-cookie@7.2.0)(vue@3.4.33(typescript@5.3.3))
       '@xterm/addon-attach':
         specifier: ^0.11.0
         version: 0.11.0(@xterm/xterm@5.5.0)
@@ -68,6 +71,9 @@ importers:
       sortablejs:
         specifier: ^1.15.2
         version: 1.15.2
+      universal-cookie:
+        specifier: ^7
+        version: 7.2.0
       vite-plugin-build-id:
         specifier: ^0.2.9
         version: 0.2.9(less@4.2.0)
@@ -89,6 +95,9 @@ importers:
       vue3-gettext:
         specifier: 3.0.0-beta.4
         version: 3.0.0-beta.4(@vue/compiler-sfc@3.4.33)(typescript@5.3.3)(vue@3.4.33(typescript@5.3.3))
+      vue3-otp-input:
+        specifier: ^0.5.21
+        version: 0.5.21(vue@3.4.33(typescript@5.3.3))
       vuedraggable:
         specifier: ^4.1.0
         version: 4.1.0(vue@3.4.33(typescript@5.3.3))
@@ -695,6 +704,9 @@ packages:
     resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
     engines: {node: '>=10.13.0'}
 
+  '@types/cookie@0.6.0':
+    resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
+
   '@types/estree@1.0.5':
     resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
 
@@ -922,6 +934,47 @@ packages:
   '@vueuse/core@10.11.0':
     resolution: {integrity: sha512-x3sD4Mkm7PJ+pcq3HX8PLPBadXCAlSDR/waK87dz0gQE+qJnaaFhc/dZVfJz+IUYzTMVGum2QlR7ImiJQN4s6g==}
 
+  '@vueuse/integrations@10.11.0':
+    resolution: {integrity: sha512-Pp6MtWEIr+NDOccWd8j59Kpjy5YDXogXI61Kb1JxvSfVBO8NzFQkmrKmSZz47i+ZqHnIzxaT38L358yDHTncZg==}
+    peerDependencies:
+      async-validator: ^4
+      axios: ^1
+      change-case: ^4
+      drauu: ^0.3
+      focus-trap: ^7
+      fuse.js: ^6
+      idb-keyval: ^6
+      jwt-decode: ^3
+      nprogress: ^0.2
+      qrcode: ^1.5
+      sortablejs: ^1
+      universal-cookie: ^6
+    peerDependenciesMeta:
+      async-validator:
+        optional: true
+      axios:
+        optional: true
+      change-case:
+        optional: true
+      drauu:
+        optional: true
+      focus-trap:
+        optional: true
+      fuse.js:
+        optional: true
+      idb-keyval:
+        optional: true
+      jwt-decode:
+        optional: true
+      nprogress:
+        optional: true
+      qrcode:
+        optional: true
+      sortablejs:
+        optional: true
+      universal-cookie:
+        optional: true
+
   '@vueuse/metadata@10.11.0':
     resolution: {integrity: sha512-kQX7l6l8dVWNqlqyN3ePW3KmjCQO3ZMgXuBMddIu83CmucrsBfXlH+JoviYyRBws/yLTQO8g3Pbw+bdIoVm4oQ==}
 
@@ -1197,6 +1250,10 @@ packages:
   convert-source-map@2.0.0:
     resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
 
+  cookie@0.6.0:
+    resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
+    engines: {node: '>= 0.6'}
+
   copy-anything@2.0.6:
     resolution: {integrity: sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==}
 
@@ -2824,6 +2881,9 @@ packages:
   unist-util-stringify-position@2.0.3:
     resolution: {integrity: sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==}
 
+  universal-cookie@7.2.0:
+    resolution: {integrity: sha512-PvcyflJAYACJKr28HABxkGemML5vafHmiL4ICe3e+BEKXRMt0GaFLZhAwgv637kFFnnfiSJ8e6jknrKkMrU+PQ==}
+
   unplugin-auto-import@0.17.8:
     resolution: {integrity: sha512-CHryj6HzJ+n4ASjzwHruD8arhbdl+UXvhuAIlHDs15Y/IMecG3wrf7FVg4pVH/DIysbq/n0phIjNHAjl7TG7Iw==}
     engines: {node: '>=14'}
@@ -2969,6 +3029,11 @@ packages:
       '@vue/compiler-sfc': '>=3.0.0'
       vue: '>=3.0.0'
 
+  vue3-otp-input@0.5.21:
+    resolution: {integrity: sha512-dRxmGJqXlU+U5dCijNCyY7ird49+pyfeQspSTqvIp2Xs+VByIluNlTOjgHrftzSdeVZggtx+Ojb8uKiRLaob4Q==}
+    peerDependencies:
+      vue: ^3.0.*
+
   vue@3.4.33:
     resolution: {integrity: sha512-VdMCWQOummbhctl4QFMcW6eNtXHsFyDlX60O/tsSQuCcuDOnJ1qPOhhVla65Niece7xq/P2zyZReIO5mP+LGTQ==}
     peerDependencies:
@@ -3572,6 +3637,8 @@ snapshots:
 
   '@trysound/sax@0.2.0': {}
 
+  '@types/cookie@0.6.0': {}
+
   '@types/estree@1.0.5': {}
 
   '@types/glob@7.2.0':
@@ -3890,6 +3957,21 @@ snapshots:
       - '@vue/composition-api'
       - vue
 
+  '@vueuse/integrations@10.11.0(async-validator@4.2.5)(axios@1.7.2)(nprogress@0.2.0)(sortablejs@1.15.2)(universal-cookie@7.2.0)(vue@3.4.33(typescript@5.3.3))':
+    dependencies:
+      '@vueuse/core': 10.11.0(vue@3.4.33(typescript@5.3.3))
+      '@vueuse/shared': 10.11.0(vue@3.4.33(typescript@5.3.3))
+      vue-demi: 0.14.8(vue@3.4.33(typescript@5.3.3))
+    optionalDependencies:
+      async-validator: 4.2.5
+      axios: 1.7.2
+      nprogress: 0.2.0
+      sortablejs: 1.15.2
+      universal-cookie: 7.2.0
+    transitivePeerDependencies:
+      - '@vue/composition-api'
+      - vue
+
   '@vueuse/metadata@10.11.0': {}
 
   '@vueuse/shared@10.11.0(vue@3.4.33(typescript@5.3.3))':
@@ -4201,6 +4283,8 @@ snapshots:
 
   convert-source-map@2.0.0: {}
 
+  cookie@0.6.0: {}
+
   copy-anything@2.0.6:
     dependencies:
       is-what: 3.14.1
@@ -6009,6 +6093,11 @@ snapshots:
     dependencies:
       '@types/unist': 2.0.10
 
+  universal-cookie@7.2.0:
+    dependencies:
+      '@types/cookie': 0.6.0
+      cookie: 0.6.0
+
   unplugin-auto-import@0.17.8(@vueuse/core@10.11.0(vue@3.4.33(typescript@5.3.3)))(rollup@4.19.0):
     dependencies:
       '@antfu/utils': 0.7.10
@@ -6175,6 +6264,10 @@ snapshots:
     transitivePeerDependencies:
       - typescript
 
+  vue3-otp-input@0.5.21(vue@3.4.33(typescript@5.3.3)):
+    dependencies:
+      vue: 3.4.33(typescript@5.3.3)
+
   vue@3.4.33(typescript@5.3.3):
     dependencies:
       '@vue/compiler-dom': 3.4.33

+ 4 - 3
app/src/api/auth.ts

@@ -6,15 +6,16 @@ const { login, logout } = useUserStore()
 export interface AuthResponse {
   message: string
   token: string
+  code: number
 }
 
 const auth = {
-  async login(name: string, password: string) {
+  async login(name: string, password: string, otp: string, recoveryCode: string): Promise<AuthResponse> {
     return http.post('/login', {
       name,
       password,
-    }).then((r: AuthResponse) => {
-      login(r.token)
+      otp,
+      recovery_code: recoveryCode,
     })
   },
   async casdoor_login(code?: string, state?: string) {

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

@@ -0,0 +1,29 @@
+import http from '@/lib/http'
+
+export interface OTPGenerateSecretResponse {
+  secret: string
+  qr_code: string
+}
+
+const otp = {
+  status(): Promise<{ status: boolean }> {
+    return http.get('/otp_status')
+  },
+  generate_secret(): Promise<OTPGenerateSecretResponse> {
+    return http.get('/otp_secret')
+  },
+  enroll_otp(secret: string, passcode: string): Promise<{ recovery_code: string }> {
+    return http.post('/otp_enroll', { secret, passcode })
+  },
+  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 - 0
app/src/components/OTP/OTPAuthorization.vue

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

+ 92 - 0
app/src/components/OTP/useOTPModal.ts

@@ -0,0 +1,92 @@
+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'
+
+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 = async ({ onOk, onCancel }: OTPModalProps) => {
+    const { status } = await otp.status()
+    if (!status) {
+      onOk?.('')
+
+      return
+    }
+
+    const cookies = useCookies(['nginx-ui-2fa'])
+    const ssid = cookies.get('secure_session_id')
+    if (ssid) {
+      onOk?.(ssid)
+
+      return
+    }
+
+    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 => {
+        cookies.set('secure_session_id', r.session_id, { maxAge: 60 * 3 })
+        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

+ 72 - 0
app/src/components/OTPInput/OTPInput.vue

@@ -0,0 +1,72 @@
+<script setup lang="ts">
+import VOtpInput from 'vue3-otp-input'
+
+const emit = defineEmits(['onComplete'])
+
+const data = defineModel<string>()
+const refOtp = ref()
+
+function onComplete(value: string) {
+  emit('onComplete', value)
+}
+
+function clearInput() {
+  refOtp.value?.clearInput()
+}
+
+defineExpose({
+  clearInput,
+})
+</script>
+
+<template>
+  <VOtpInput
+    ref="refOtp"
+    v-model:value="data"
+    input-classes="otp-input"
+    :num-inputs="6"
+    input-type="number"
+    should-auto-focus
+    should-focus-order
+    @on-complete="onComplete"
+  />
+</template>
+
+<style lang="less">
+.dark {
+  .otp-input {
+    border: 1px solid rgba(255, 255, 255, 0.2) !important;
+
+    &:focus {
+      outline: none;
+      border: 2px solid #1677ff !important;
+    }
+  }
+}
+</style>
+
+<style scoped lang="less">
+:deep(.otp-input) {
+  width: 40px;
+  height: 40px;
+  padding: 5px;
+  margin: 0 10px;
+  font-size: 20px;
+  border-radius: 4px;
+  border: 1px solid rgba(0, 0, 0, 0.3);
+
+  text-align: center;
+  background-color: transparent;
+
+  &:focus {
+    outline: none;
+    border: 2px solid #1677ff;
+  }
+
+  &::-webkit-inner-spin-button,
+  &::-webkit-outer-spin-button {
+    -webkit-appearance: none;
+    margin: 0;
+  }
+}
+</style>

+ 1 - 1
app/src/language/LINGUAS

@@ -1 +1 @@
-es fr_FR ko_KR ru_RU vi_VN zh_CN zh_TW
+en zh_CN zh_TW fr_FR es ru_RU vi_VN ko_KR

+ 139 - 34
app/src/language/en/app.po

@@ -9,7 +9,11 @@ msgstr ""
 "Content-Transfer-Encoding: 8bit\n"
 "Plural-Forms: nplurals=2; plural=(n != 1);\n"
 
-#: src/routes/index.ts:256
+#: src/views/preference/components/TOTP.vue:88
+msgid "2FA Settings"
+msgstr ""
+
+#: src/routes/index.ts:261
 msgid "About"
 msgstr "About"
 
@@ -28,7 +32,7 @@ msgstr "Username"
 #: src/views/certificate/DNSCredential.vue:33 src/views/config/config.ts:34
 #: src/views/domain/DomainList.vue:47 src/views/environment/envColumns.tsx:131
 #: src/views/notification/Notification.vue:37
-#: src/views/preference/AuthSettings.vue:26 src/views/stream/StreamList.vue:47
+#: src/views/preference/AuthSettings.vue:27 src/views/stream/StreamList.vue:47
 #: src/views/user/User.vue:43
 msgid "Action"
 msgstr "Action"
@@ -95,7 +99,7 @@ msgstr ""
 msgid "Arch"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:94
+#: src/views/preference/AuthSettings.vue:95
 #, fuzzy
 msgid "Are you sure to delete this banned IP immediately?"
 msgstr "Are you sure you want to remove this directive?"
@@ -153,7 +157,7 @@ msgstr ""
 msgid "Assistant"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:17
+#: src/views/preference/AuthSettings.vue:18
 msgid "Attempts"
 msgstr ""
 
@@ -198,15 +202,15 @@ msgstr "Back"
 msgid "Back to list"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:68
+#: src/views/preference/AuthSettings.vue:69
 msgid "Ban Threshold Minutes"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:82
+#: src/views/preference/AuthSettings.vue:83
 msgid "Banned IPs"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:20
+#: src/views/preference/AuthSettings.vue:21
 msgid "Banned Until"
 msgstr ""
 
@@ -362,6 +366,10 @@ msgstr "Configure SSL"
 msgid "Connected"
 msgstr ""
 
+#: src/views/pty/Terminal.vue:120
+msgid "Connection lost, please refresh the page."
+msgstr ""
+
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:111
 #: src/views/domain/ngx_conf/LocationEditor.vue:125
 #: src/views/domain/ngx_conf/LocationEditor.vue:97
@@ -405,6 +413,14 @@ msgstr ""
 msgid "Credentials"
 msgstr ""
 
+#: src/views/preference/components/TOTP.vue:96
+msgid "Current account is enabled 2FA."
+msgstr ""
+
+#: src/views/preference/components/TOTP.vue:93
+msgid "Current account is not enabled 2FA."
+msgstr ""
+
 #: src/views/system/Upgrade.vue:167
 msgid "Current Version"
 msgstr ""
@@ -692,6 +708,16 @@ msgstr ""
 msgid "Enable %{conf_name} in %{node_name} successfully"
 msgstr ""
 
+#: src/views/preference/components/TOTP.vue:122
+#, fuzzy
+msgid "Enable 2FA"
+msgstr "Enabled"
+
+#: src/views/preference/components/TOTP.vue:52
+#, fuzzy
+msgid "Enable 2FA successfully"
+msgstr "Enabled successfully"
+
 #: src/views/domain/cert/components/ObtainCert.vue:70
 msgid "Enable auto-renewal failed for %{name}"
 msgstr "Enable auto-renewal failed for %{name}"
@@ -893,12 +919,18 @@ msgstr ""
 msgid "If left blank, the default CA Dir will be used."
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:60
+#: src/views/preference/AuthSettings.vue:61
 msgid ""
 "If the number of login failed attempts from a ip reach the max attempts in "
 "ban threshold minutes, the ip will be banned for a period of time."
 msgstr ""
 
+#: src/views/preference/components/TOTP.vue:108
+msgid ""
+"If you lose your mobile phone, you can use the recovery code to reset your "
+"2FA."
+msgstr ""
+
 #: src/views/certificate/Certificate.vue:136
 msgid "Import"
 msgstr ""
@@ -908,7 +940,7 @@ msgstr ""
 msgid "Import Certificate"
 msgstr "Certificate Status"
 
-#: src/views/other/Login.vue:59
+#: src/views/other/Login.vue:71
 msgid "Incorrect username or password"
 msgstr ""
 
@@ -924,7 +956,16 @@ msgstr ""
 msgid "Initialing core upgrader"
 msgstr ""
 
-#: src/routes/index.ts:273 src/views/other/Install.vue:135
+#: src/views/preference/components/TOTP.vue:144
+msgid "Input the code from the app:"
+msgstr ""
+
+#: src/components/OTP/OTPAuthorization.vue:49
+#: src/views/preference/components/TOTP.vue:157
+msgid "Input the recovery code:"
+msgstr ""
+
+#: src/routes/index.ts:283 src/views/other/Install.vue:135
 msgid "Install"
 msgstr "Install"
 
@@ -946,7 +987,15 @@ msgstr ""
 msgid "Invalid"
 msgstr "Invalid E-mail!"
 
-#: src/views/preference/AuthSettings.vue:14
+#: src/views/other/Login.vue:81
+msgid "Invalid 2FA or recovery code"
+msgstr ""
+
+#: src/components/OTP/useOTPModal.ts:43
+msgid "Invalid passcode or recovery code"
+msgstr ""
+
+#: src/views/preference/AuthSettings.vue:15
 msgid "IP"
 msgstr ""
 
@@ -1040,11 +1089,11 @@ msgstr "Locations"
 msgid "Log"
 msgstr "Login"
 
-#: src/routes/index.ts:279 src/views/other/Login.vue:159
+#: src/routes/index.ts:289 src/views/other/Login.vue:192
 msgid "Login"
 msgstr "Login"
 
-#: src/views/other/Login.vue:109 src/views/other/Login.vue:51
+#: src/views/other/Login.vue:125 src/views/other/Login.vue:60
 msgid "Login successful"
 msgstr "Login successful"
 
@@ -1088,7 +1137,7 @@ msgstr "Manage Sites"
 msgid "Manage Streams"
 msgstr "Manage Sites"
 
-#: src/routes/index.ts:230 src/views/user/User.vue:50
+#: src/routes/index.ts:235 src/views/user/User.vue:50
 msgid "Manage Users"
 msgstr "Manage Users"
 
@@ -1097,7 +1146,7 @@ msgstr "Manage Users"
 msgid "Managed Certificate"
 msgstr "Certificate is valid"
 
-#: src/views/preference/AuthSettings.vue:74
+#: src/views/preference/AuthSettings.vue:75
 msgid "Max Attempts"
 msgstr ""
 
@@ -1231,7 +1280,7 @@ msgstr "Saved successfully"
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:90
 #: src/views/domain/ngx_conf/LocationEditor.vue:71
 #: src/views/notification/Notification.vue:70
-#: src/views/preference/AuthSettings.vue:96
+#: src/views/preference/AuthSettings.vue:97
 #: src/views/preference/BasicSettings.vue:101
 #: src/views/stream/StreamList.vue:165
 msgid "No"
@@ -1245,7 +1294,7 @@ msgstr ""
 msgid "Not After"
 msgstr ""
 
-#: src/routes/index.ts:285
+#: src/routes/index.ts:295
 msgid "Not Found"
 msgstr "Not Found"
 
@@ -1263,7 +1312,7 @@ msgstr ""
 msgid "Notification"
 msgstr "Certificate is valid"
 
-#: src/components/Notification/Notification.vue:82 src/routes/index.ts:221
+#: src/components/Notification/Notification.vue:82 src/routes/index.ts:226
 #, fuzzy
 msgid "Notifications"
 msgstr "Certificate is valid"
@@ -1346,7 +1395,7 @@ msgstr ""
 msgid "Params"
 msgstr "Params"
 
-#: src/views/other/Login.vue:144 src/views/user/User.vue:18
+#: src/views/other/Login.vue:169 src/views/user/User.vue:18
 msgid "Password"
 msgstr "Password"
 
@@ -1372,6 +1421,10 @@ msgstr ""
 msgid "Performing core upgrade"
 msgstr ""
 
+#: src/components/OTP/OTPAuthorization.vue:37
+msgid "Please enter the 2FA code:"
+msgstr ""
+
 #: src/views/certificate/DNSCredential.vue:53
 msgid ""
 "Please fill in the API authentication credentials provided by your DNS "
@@ -1399,11 +1452,11 @@ msgstr ""
 msgid "Please input your E-mail!"
 msgstr "Please input your E-mail!"
 
-#: src/views/other/Install.vue:45 src/views/other/Login.vue:39
+#: src/views/other/Install.vue:45 src/views/other/Login.vue:43
 msgid "Please input your password!"
 msgstr "Please input your password!"
 
-#: src/views/other/Install.vue:39 src/views/other/Login.vue:33
+#: src/views/other/Install.vue:39 src/views/other/Login.vue:37
 msgid "Please input your username!"
 msgstr "Please input your username!"
 
@@ -1423,7 +1476,7 @@ msgstr ""
 msgid "Pre-release"
 msgstr ""
 
-#: src/routes/index.ts:239 src/views/preference/Preference.vue:105
+#: src/routes/index.ts:244 src/views/preference/Preference.vue:105
 msgid "Preference"
 msgstr ""
 
@@ -1467,6 +1520,19 @@ msgstr ""
 msgid "Recovered Successfully"
 msgstr "Saved successfully"
 
+#: src/components/OTP/OTPAuthorization.vue:56
+#: src/views/preference/components/TOTP.vue:167
+msgid "Recovery"
+msgstr ""
+
+#: src/views/preference/components/TOTP.vue:101
+msgid "Recovery Code"
+msgstr ""
+
+#: src/views/preference/components/TOTP.vue:110
+msgid "Recovery Code:"
+msgstr ""
+
 #: src/views/preference/BasicSettings.vue:68
 msgid "Recursive Nameservers"
 msgstr ""
@@ -1519,11 +1585,11 @@ msgstr ""
 msgid "Reloading nginx"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:101
+#: src/views/preference/AuthSettings.vue:102
 msgid "Remove"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:47
+#: src/views/preference/AuthSettings.vue:48
 #, fuzzy
 msgid "Remove successfully"
 msgstr "Saved successfully"
@@ -1568,6 +1634,10 @@ msgstr ""
 msgid "Reset"
 msgstr ""
 
+#: src/views/preference/components/TOTP.vue:130
+msgid "Reset 2FA"
+msgstr ""
+
 #: src/components/NginxControl/NginxControl.vue:93
 msgid "Restart"
 msgstr ""
@@ -1617,6 +1687,10 @@ msgstr "Saved successfully"
 msgid "Saved successfully"
 msgstr "Saved successfully"
 
+#: src/views/preference/components/TOTP.vue:91
+msgid "Scan the QR code with your mobile phone to add the account to the app."
+msgstr ""
+
 #: src/views/certificate/DNSChallenge.vue:89
 msgid "SDK"
 msgstr ""
@@ -1640,7 +1714,9 @@ msgstr "Send"
 #: src/views/config/ConfigEdit.vue:40 src/views/domain/DomainList.vue:81
 #: src/views/environment/BatchUpgrader.vue:57
 #: src/views/environment/Environment.vue:15 src/views/other/Install.vue:69
-#: src/views/preference/AuthSettings.vue:49
+#: src/views/preference/AuthSettings.vue:50
+#: src/views/preference/components/TOTP.vue:42
+#: src/views/preference/components/TOTP.vue:55
 #: src/views/preference/Preference.vue:78 src/views/stream/StreamList.vue:113
 #: src/views/stream/StreamList.vue:81 src/views/system/Upgrade.vue:42
 msgid "Server error"
@@ -1717,7 +1793,7 @@ msgstr "Certificate Status"
 msgid "SSL Certificate Path"
 msgstr "Certificate Status"
 
-#: src/views/other/Login.vue:170
+#: src/views/other/Login.vue:203
 #, fuzzy
 msgid "SSO Login"
 msgstr "Login"
@@ -1802,7 +1878,7 @@ msgstr "Certificate is valid"
 msgid "Sync to"
 msgstr ""
 
-#: src/routes/index.ts:248
+#: src/routes/index.ts:253
 msgid "System"
 msgstr ""
 
@@ -1811,7 +1887,7 @@ msgstr ""
 msgid "Target"
 msgstr ""
 
-#: src/routes/index.ts:172 src/views/pty/Terminal.vue:91
+#: src/routes/index.ts:172 src/views/pty/Terminal.vue:114
 msgid "Terminal"
 msgstr "Terminal"
 
@@ -1856,6 +1932,11 @@ msgstr "Certificate Status"
 msgid "The path exists, but the file is not a private key"
 msgstr ""
 
+#: src/views/preference/components/TOTP.vue:109
+msgid ""
+"The recovery code is only displayed once, please save it in a safe place."
+msgstr ""
+
 #: src/views/dashboard/Environments.vue:148
 msgid ""
 "The remote Nginx UI version is not compatible with the local Nginx UI "
@@ -1915,7 +1996,7 @@ msgid ""
 "This will upgrade or reinstall the Nginx UI on %{nodeNames} to %{version}."
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:59
+#: src/views/preference/AuthSettings.vue:60
 #: src/views/preference/LogrotateSettings.vue:12
 msgid "Tips"
 msgstr ""
@@ -1924,6 +2005,12 @@ msgstr ""
 msgid "Title"
 msgstr ""
 
+#: src/views/preference/components/TOTP.vue:90
+msgid ""
+"To enable it, you need to install the Google or Microsoft Authenticator app "
+"on your mobile phone."
+msgstr ""
+
 #: src/views/domain/ngx_conf/NgxConfigEditor.vue:44
 msgid ""
 "To make sure the certification auto-renewal can work normally, we need to "
@@ -1936,14 +2023,24 @@ msgstr ""
 msgid "Token is not valid"
 msgstr ""
 
-#: src/views/other/Login.vue:62
+#: src/views/other/Login.vue:74
 msgid "Too many login failed attempts, please try again later"
 msgstr ""
 
+#: src/views/preference/components/TOTP.vue:89
+msgid ""
+"TOTP is a two-factor authentication method that uses a time-based one-time "
+"password algorithm."
+msgstr ""
+
 #: src/components/StdDesign/StdDataDisplay/StdCurd.vue:209
 msgid "Trash"
 msgstr ""
 
+#: src/components/OTP/useOTPModal.ts:49
+msgid "Two-factor authentication required"
+msgstr ""
+
 #: src/views/certificate/Certificate.vue:37 src/views/config/config.ts:12
 #: src/views/notification/Notification.vue:13
 msgid "Type"
@@ -1964,7 +2061,7 @@ msgstr "Updated at"
 msgid "Updated successfully"
 msgstr "Saved successfully"
 
-#: src/routes/index.ts:263 src/views/environment/Environment.vue:50
+#: src/routes/index.ts:268 src/views/environment/Environment.vue:50
 #: src/views/system/Upgrade.vue:145 src/views/system/Upgrade.vue:228
 msgid "Upgrade"
 msgstr ""
@@ -1995,16 +2092,24 @@ msgstr "Uptime:"
 msgid "URL"
 msgstr ""
 
+#: src/components/OTP/OTPAuthorization.vue:69
+msgid "Use OTP"
+msgstr ""
+
+#: src/components/OTP/OTPAuthorization.vue:65
+msgid "Use recovery code"
+msgstr ""
+
 #: src/components/ChatGPT/ChatGPT.vue:229
 #, fuzzy
 msgid "User"
 msgstr "Username"
 
-#: src/views/other/Login.vue:65
+#: src/views/other/Login.vue:77
 msgid "User is banned"
 msgstr ""
 
-#: src/views/other/Login.vue:134 src/views/user/User.vue:9
+#: src/views/other/Login.vue:159 src/views/user/User.vue:9
 msgid "Username"
 msgstr "Username"
 
@@ -2073,7 +2178,7 @@ msgstr ""
 
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:89
 #: src/views/domain/ngx_conf/LocationEditor.vue:70
-#: src/views/preference/AuthSettings.vue:95
+#: src/views/preference/AuthSettings.vue:96
 #: src/views/preference/BasicSettings.vue:100
 msgid "Yes"
 msgstr "Yes"

+ 140 - 34
app/src/language/es/app.po

@@ -14,7 +14,11 @@ msgstr ""
 "Plural-Forms: nplurals=2; plural=n != 1;\n"
 "X-Generator: Weblate 5.3.1\n"
 
-#: src/routes/index.ts:256
+#: src/views/preference/components/TOTP.vue:88
+msgid "2FA Settings"
+msgstr ""
+
+#: src/routes/index.ts:261
 msgid "About"
 msgstr "Acerca de"
 
@@ -33,7 +37,7 @@ msgstr "Usuario"
 #: src/views/certificate/DNSCredential.vue:33 src/views/config/config.ts:34
 #: src/views/domain/DomainList.vue:47 src/views/environment/envColumns.tsx:131
 #: src/views/notification/Notification.vue:37
-#: src/views/preference/AuthSettings.vue:26 src/views/stream/StreamList.vue:47
+#: src/views/preference/AuthSettings.vue:27 src/views/stream/StreamList.vue:47
 #: src/views/user/User.vue:43
 msgid "Action"
 msgstr "Acción"
@@ -98,7 +102,7 @@ msgstr "Token de la API"
 msgid "Arch"
 msgstr "Arquitectura"
 
-#: src/views/preference/AuthSettings.vue:94
+#: src/views/preference/AuthSettings.vue:95
 #, fuzzy
 msgid "Are you sure to delete this banned IP immediately?"
 msgstr "¿Está seguro de que quiere borrar?"
@@ -152,7 +156,7 @@ msgstr "Preguntar por ayuda a ChatGPT"
 msgid "Assistant"
 msgstr "Asistente"
 
-#: src/views/preference/AuthSettings.vue:17
+#: src/views/preference/AuthSettings.vue:18
 msgid "Attempts"
 msgstr ""
 
@@ -197,15 +201,15 @@ msgstr "Volver al Inicio"
 msgid "Back to list"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:68
+#: src/views/preference/AuthSettings.vue:69
 msgid "Ban Threshold Minutes"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:82
+#: src/views/preference/AuthSettings.vue:83
 msgid "Banned IPs"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:20
+#: src/views/preference/AuthSettings.vue:21
 msgid "Banned Until"
 msgstr ""
 
@@ -355,6 +359,10 @@ msgstr "Configurar SSL"
 msgid "Connected"
 msgstr "Conectado"
 
+#: src/views/pty/Terminal.vue:120
+msgid "Connection lost, please refresh the page."
+msgstr ""
+
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:111
 #: src/views/domain/ngx_conf/LocationEditor.vue:125
 #: src/views/domain/ngx_conf/LocationEditor.vue:97
@@ -397,6 +405,14 @@ msgstr "Credencial"
 msgid "Credentials"
 msgstr "Credenciales"
 
+#: src/views/preference/components/TOTP.vue:96
+msgid "Current account is enabled 2FA."
+msgstr ""
+
+#: src/views/preference/components/TOTP.vue:93
+msgid "Current account is not enabled 2FA."
+msgstr ""
+
 #: src/views/system/Upgrade.vue:167
 msgid "Current Version"
 msgstr "Versión actual"
@@ -668,6 +684,16 @@ msgstr "Falló el habilitado de %{conf_name} en %{node_name}"
 msgid "Enable %{conf_name} in %{node_name} successfully"
 msgstr "Habilitado exitoso de %{conf_name} en %{node_name}"
 
+#: src/views/preference/components/TOTP.vue:122
+#, fuzzy
+msgid "Enable 2FA"
+msgstr "Habilitar"
+
+#: src/views/preference/components/TOTP.vue:52
+#, fuzzy
+msgid "Enable 2FA successfully"
+msgstr "Habilitado con Éxito"
+
 #: src/views/domain/cert/components/ObtainCert.vue:70
 msgid "Enable auto-renewal failed for %{name}"
 msgstr "No se pudo activar la renovación automática por %{name}"
@@ -863,12 +889,18 @@ msgstr "HTTP01"
 msgid "If left blank, the default CA Dir will be used."
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:60
+#: src/views/preference/AuthSettings.vue:61
 msgid ""
 "If the number of login failed attempts from a ip reach the max attempts in "
 "ban threshold minutes, the ip will be banned for a period of time."
 msgstr ""
 
+#: src/views/preference/components/TOTP.vue:108
+msgid ""
+"If you lose your mobile phone, you can use the recovery code to reset your "
+"2FA."
+msgstr ""
+
 #: src/views/certificate/Certificate.vue:136
 msgid "Import"
 msgstr "Importar"
@@ -877,7 +909,7 @@ msgstr "Importar"
 msgid "Import Certificate"
 msgstr "Importar Certificado"
 
-#: src/views/other/Login.vue:59
+#: src/views/other/Login.vue:71
 #, fuzzy
 msgid "Incorrect username or password"
 msgstr "El nombre de usuario o contraseña son incorrectos"
@@ -894,7 +926,16 @@ msgstr "Error de actualización de kernel inicial"
 msgid "Initialing core upgrader"
 msgstr "Inicializando la actualización del kernel"
 
-#: src/routes/index.ts:273 src/views/other/Install.vue:135
+#: src/views/preference/components/TOTP.vue:144
+msgid "Input the code from the app:"
+msgstr ""
+
+#: src/components/OTP/OTPAuthorization.vue:49
+#: src/views/preference/components/TOTP.vue:157
+msgid "Input the recovery code:"
+msgstr ""
+
+#: src/routes/index.ts:283 src/views/other/Install.vue:135
 msgid "Install"
 msgstr "Instalar"
 
@@ -915,7 +956,15 @@ msgstr ""
 msgid "Invalid"
 msgstr "Válido"
 
-#: src/views/preference/AuthSettings.vue:14
+#: src/views/other/Login.vue:81
+msgid "Invalid 2FA or recovery code"
+msgstr ""
+
+#: src/components/OTP/useOTPModal.ts:43
+msgid "Invalid passcode or recovery code"
+msgstr ""
+
+#: src/views/preference/AuthSettings.vue:15
 msgid "IP"
 msgstr ""
 
@@ -1002,11 +1051,11 @@ msgstr "Ubicaciones"
 msgid "Log"
 msgstr "Registro"
 
-#: src/routes/index.ts:279 src/views/other/Login.vue:159
+#: src/routes/index.ts:289 src/views/other/Login.vue:192
 msgid "Login"
 msgstr "Acceso"
 
-#: src/views/other/Login.vue:109 src/views/other/Login.vue:51
+#: src/views/other/Login.vue:125 src/views/other/Login.vue:60
 msgid "Login successful"
 msgstr "Acceso exitoso"
 
@@ -1048,7 +1097,7 @@ msgstr "Administrar sitios"
 msgid "Manage Streams"
 msgstr "Administrar Transmisiones"
 
-#: src/routes/index.ts:230 src/views/user/User.vue:50
+#: src/routes/index.ts:235 src/views/user/User.vue:50
 msgid "Manage Users"
 msgstr "Administrar usuarios"
 
@@ -1056,7 +1105,7 @@ msgstr "Administrar usuarios"
 msgid "Managed Certificate"
 msgstr "Certificado Administrado"
 
-#: src/views/preference/AuthSettings.vue:74
+#: src/views/preference/AuthSettings.vue:75
 msgid "Max Attempts"
 msgstr ""
 
@@ -1184,7 +1233,7 @@ msgstr "Nginx reiniciado con éxito"
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:90
 #: src/views/domain/ngx_conf/LocationEditor.vue:71
 #: src/views/notification/Notification.vue:70
-#: src/views/preference/AuthSettings.vue:96
+#: src/views/preference/AuthSettings.vue:97
 #: src/views/preference/BasicSettings.vue:101
 #: src/views/stream/StreamList.vue:165
 msgid "No"
@@ -1198,7 +1247,7 @@ msgstr "Secreto del nodo"
 msgid "Not After"
 msgstr "No después de"
 
-#: src/routes/index.ts:285
+#: src/routes/index.ts:295
 msgid "Not Found"
 msgstr "No encontrado"
 
@@ -1215,7 +1264,7 @@ msgstr "Nota"
 msgid "Notification"
 msgstr "Notificación"
 
-#: src/components/Notification/Notification.vue:82 src/routes/index.ts:221
+#: src/components/Notification/Notification.vue:82 src/routes/index.ts:226
 msgid "Notifications"
 msgstr "Notificaciones"
 
@@ -1295,7 +1344,7 @@ msgstr "Sobrescribir archivo existente"
 msgid "Params"
 msgstr "Parámetros"
 
-#: src/views/other/Login.vue:144 src/views/user/User.vue:18
+#: src/views/other/Login.vue:169 src/views/user/User.vue:18
 msgid "Password"
 msgstr "Contraseña"
 
@@ -1321,6 +1370,10 @@ msgstr "Error al ejecutar la actualización del kernel"
 msgid "Performing core upgrade"
 msgstr "Realizando la actualizaciónd el kernel"
 
+#: src/components/OTP/OTPAuthorization.vue:37
+msgid "Please enter the 2FA code:"
+msgstr ""
+
 #: src/views/certificate/DNSCredential.vue:53
 msgid ""
 "Please fill in the API authentication credentials provided by your DNS "
@@ -1355,11 +1408,11 @@ msgstr ""
 msgid "Please input your E-mail!"
 msgstr "¡Por favor ingrese su correo electrónico!"
 
-#: src/views/other/Install.vue:45 src/views/other/Login.vue:39
+#: src/views/other/Install.vue:45 src/views/other/Login.vue:43
 msgid "Please input your password!"
 msgstr "¡Por favor ingrese su contraseña!"
 
-#: src/views/other/Install.vue:39 src/views/other/Login.vue:33
+#: src/views/other/Install.vue:39 src/views/other/Login.vue:37
 msgid "Please input your username!"
 msgstr "¡Por favor ingrese su nombre de usuario!"
 
@@ -1381,7 +1434,7 @@ msgstr "¡Seleccione al menos un nodo!"
 msgid "Pre-release"
 msgstr "Prelanzamiento"
 
-#: src/routes/index.ts:239 src/views/preference/Preference.vue:105
+#: src/routes/index.ts:244 src/views/preference/Preference.vue:105
 msgid "Preference"
 msgstr "Configuración"
 
@@ -1424,6 +1477,19 @@ msgstr ""
 msgid "Recovered Successfully"
 msgstr "Eliminado con éxito"
 
+#: src/components/OTP/OTPAuthorization.vue:56
+#: src/views/preference/components/TOTP.vue:167
+msgid "Recovery"
+msgstr ""
+
+#: src/views/preference/components/TOTP.vue:101
+msgid "Recovery Code"
+msgstr ""
+
+#: src/views/preference/components/TOTP.vue:110
+msgid "Recovery Code:"
+msgstr ""
+
 #: src/views/preference/BasicSettings.vue:68
 msgid "Recursive Nameservers"
 msgstr ""
@@ -1477,11 +1543,11 @@ msgstr "Recargando"
 msgid "Reloading nginx"
 msgstr "Recargando Nginx"
 
-#: src/views/preference/AuthSettings.vue:101
+#: src/views/preference/AuthSettings.vue:102
 msgid "Remove"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:47
+#: src/views/preference/AuthSettings.vue:48
 #, fuzzy
 msgid "Remove successfully"
 msgstr "Eliminado con éxito"
@@ -1520,6 +1586,11 @@ msgstr "Pedido con parámetros incorrectos"
 msgid "Reset"
 msgstr "Limpiar"
 
+#: src/views/preference/components/TOTP.vue:130
+#, fuzzy
+msgid "Reset 2FA"
+msgstr "Limpiar"
+
 #: src/components/NginxControl/NginxControl.vue:93
 msgid "Restart"
 msgstr "Reiniciar"
@@ -1567,6 +1638,10 @@ msgstr "Guardado con éxito"
 msgid "Saved successfully"
 msgstr "Guardado con éxito"
 
+#: src/views/preference/components/TOTP.vue:91
+msgid "Scan the QR code with your mobile phone to add the account to the app."
+msgstr ""
+
 #: src/views/certificate/DNSChallenge.vue:89
 msgid "SDK"
 msgstr ""
@@ -1590,7 +1665,9 @@ msgstr "Enviado"
 #: src/views/config/ConfigEdit.vue:40 src/views/domain/DomainList.vue:81
 #: src/views/environment/BatchUpgrader.vue:57
 #: src/views/environment/Environment.vue:15 src/views/other/Install.vue:69
-#: src/views/preference/AuthSettings.vue:49
+#: src/views/preference/AuthSettings.vue:50
+#: src/views/preference/components/TOTP.vue:42
+#: src/views/preference/components/TOTP.vue:55
 #: src/views/preference/Preference.vue:78 src/views/stream/StreamList.vue:113
 #: src/views/stream/StreamList.vue:81 src/views/system/Upgrade.vue:42
 msgid "Server error"
@@ -1662,7 +1739,7 @@ msgstr "Ruta de la llave del certificado SSL"
 msgid "SSL Certificate Path"
 msgstr "Ruta del certificado SSL"
 
-#: src/views/other/Login.vue:170
+#: src/views/other/Login.vue:203
 msgid "SSO Login"
 msgstr "Acceso SSO"
 
@@ -1744,7 +1821,7 @@ msgstr "Renovado de Certificado exitoso"
 msgid "Sync to"
 msgstr ""
 
-#: src/routes/index.ts:248
+#: src/routes/index.ts:253
 msgid "System"
 msgstr "Sistema"
 
@@ -1753,7 +1830,7 @@ msgstr "Sistema"
 msgid "Target"
 msgstr "Objetivo"
 
-#: src/routes/index.ts:172 src/views/pty/Terminal.vue:91
+#: src/routes/index.ts:172 src/views/pty/Terminal.vue:114
 msgid "Terminal"
 msgstr "Terminal"
 
@@ -1797,6 +1874,11 @@ msgstr "La ruta existe, pero el archivo no es una clave privada"
 msgid "The path exists, but the file is not a private key"
 msgstr "La ruta existe, pero el archivo no es una clave privada"
 
+#: src/views/preference/components/TOTP.vue:109
+msgid ""
+"The recovery code is only displayed once, please save it in a safe place."
+msgstr ""
+
 #: src/views/dashboard/Environments.vue:148
 msgid ""
 "The remote Nginx UI version is not compatible with the local Nginx UI "
@@ -1857,7 +1939,7 @@ msgid ""
 "This will upgrade or reinstall the Nginx UI on %{nodeNames} to %{version}."
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:59
+#: src/views/preference/AuthSettings.vue:60
 #: src/views/preference/LogrotateSettings.vue:12
 msgid "Tips"
 msgstr ""
@@ -1866,6 +1948,12 @@ msgstr ""
 msgid "Title"
 msgstr "Título"
 
+#: src/views/preference/components/TOTP.vue:90
+msgid ""
+"To enable it, you need to install the Google or Microsoft Authenticator app "
+"on your mobile phone."
+msgstr ""
+
 #: src/views/domain/ngx_conf/NgxConfigEditor.vue:44
 msgid ""
 "To make sure the certification auto-renewal can work normally, we need to "
@@ -1882,14 +1970,24 @@ msgstr ""
 msgid "Token is not valid"
 msgstr "El token no es válido"
 
-#: src/views/other/Login.vue:62
+#: src/views/other/Login.vue:74
 msgid "Too many login failed attempts, please try again later"
 msgstr ""
 
+#: src/views/preference/components/TOTP.vue:89
+msgid ""
+"TOTP is a two-factor authentication method that uses a time-based one-time "
+"password algorithm."
+msgstr ""
+
 #: src/components/StdDesign/StdDataDisplay/StdCurd.vue:209
 msgid "Trash"
 msgstr ""
 
+#: src/components/OTP/useOTPModal.ts:49
+msgid "Two-factor authentication required"
+msgstr ""
+
 #: src/views/certificate/Certificate.vue:37 src/views/config/config.ts:12
 #: src/views/notification/Notification.vue:13
 msgid "Type"
@@ -1909,7 +2007,7 @@ msgstr "Actualizado a"
 msgid "Updated successfully"
 msgstr "Actualización exitosa"
 
-#: src/routes/index.ts:263 src/views/environment/Environment.vue:50
+#: src/routes/index.ts:268 src/views/environment/Environment.vue:50
 #: src/views/system/Upgrade.vue:145 src/views/system/Upgrade.vue:228
 msgid "Upgrade"
 msgstr "Actualizar"
@@ -1939,15 +2037,23 @@ msgstr "Tiempo encendido:"
 msgid "URL"
 msgstr "URL"
 
+#: src/components/OTP/OTPAuthorization.vue:69
+msgid "Use OTP"
+msgstr ""
+
+#: src/components/OTP/OTPAuthorization.vue:65
+msgid "Use recovery code"
+msgstr ""
+
 #: src/components/ChatGPT/ChatGPT.vue:229
 msgid "User"
 msgstr "Usuario"
 
-#: src/views/other/Login.vue:65
+#: src/views/other/Login.vue:77
 msgid "User is banned"
 msgstr ""
 
-#: src/views/other/Login.vue:134 src/views/user/User.vue:9
+#: src/views/other/Login.vue:159 src/views/user/User.vue:9
 msgid "Username"
 msgstr "Nombre de usuario"
 
@@ -2021,7 +2127,7 @@ msgstr "Escribir certificado a disco"
 
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:89
 #: src/views/domain/ngx_conf/LocationEditor.vue:70
-#: src/views/preference/AuthSettings.vue:95
+#: src/views/preference/AuthSettings.vue:96
 #: src/views/preference/BasicSettings.vue:100
 msgid "Yes"
 msgstr "Si"

+ 140 - 34
app/src/language/fr_FR/app.po

@@ -11,7 +11,11 @@ msgstr ""
 "Content-Transfer-Encoding: 8bit\n"
 "X-Generator: Poedit 3.3\n"
 
-#: src/routes/index.ts:256
+#: src/views/preference/components/TOTP.vue:88
+msgid "2FA Settings"
+msgstr ""
+
+#: src/routes/index.ts:261
 msgid "About"
 msgstr "À propos"
 
@@ -30,7 +34,7 @@ msgstr "Nom d'utilisateur"
 #: src/views/certificate/DNSCredential.vue:33 src/views/config/config.ts:34
 #: src/views/domain/DomainList.vue:47 src/views/environment/envColumns.tsx:131
 #: src/views/notification/Notification.vue:37
-#: src/views/preference/AuthSettings.vue:26 src/views/stream/StreamList.vue:47
+#: src/views/preference/AuthSettings.vue:27 src/views/stream/StreamList.vue:47
 #: src/views/user/User.vue:43
 msgid "Action"
 msgstr "Action"
@@ -99,7 +103,7 @@ msgstr "Jeton d'API"
 msgid "Arch"
 msgstr "Arch"
 
-#: src/views/preference/AuthSettings.vue:94
+#: src/views/preference/AuthSettings.vue:95
 #, fuzzy
 msgid "Are you sure to delete this banned IP immediately?"
 msgstr "Etes-vous sûr que vous voulez supprimer ?"
@@ -155,7 +159,7 @@ msgstr "Modèle ChatGPT"
 msgid "Assistant"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:17
+#: src/views/preference/AuthSettings.vue:18
 msgid "Attempts"
 msgstr ""
 
@@ -200,15 +204,15 @@ msgstr "Retour au menu principal"
 msgid "Back to list"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:68
+#: src/views/preference/AuthSettings.vue:69
 msgid "Ban Threshold Minutes"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:82
+#: src/views/preference/AuthSettings.vue:83
 msgid "Banned IPs"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:20
+#: src/views/preference/AuthSettings.vue:21
 msgid "Banned Until"
 msgstr ""
 
@@ -362,6 +366,10 @@ msgstr "Configurer SSL"
 msgid "Connected"
 msgstr ""
 
+#: src/views/pty/Terminal.vue:120
+msgid "Connection lost, please refresh the page."
+msgstr ""
+
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:111
 #: src/views/domain/ngx_conf/LocationEditor.vue:125
 #: src/views/domain/ngx_conf/LocationEditor.vue:97
@@ -405,6 +413,14 @@ msgstr "Identifiant"
 msgid "Credentials"
 msgstr "Identifiants"
 
+#: src/views/preference/components/TOTP.vue:96
+msgid "Current account is enabled 2FA."
+msgstr ""
+
+#: src/views/preference/components/TOTP.vue:93
+msgid "Current account is not enabled 2FA."
+msgstr ""
+
 #: src/views/system/Upgrade.vue:167
 msgid "Current Version"
 msgstr "Version actuelle"
@@ -692,6 +708,16 @@ msgstr ""
 msgid "Enable %{conf_name} in %{node_name} successfully"
 msgstr ""
 
+#: src/views/preference/components/TOTP.vue:122
+#, fuzzy
+msgid "Enable 2FA"
+msgstr "Activé"
+
+#: src/views/preference/components/TOTP.vue:52
+#, fuzzy
+msgid "Enable 2FA successfully"
+msgstr "Activé avec succès"
+
 #: src/views/domain/cert/components/ObtainCert.vue:70
 msgid "Enable auto-renewal failed for %{name}"
 msgstr "Échec de l'activation du renouvellement automatique pour %{name}"
@@ -894,12 +920,18 @@ msgstr "HTTP01"
 msgid "If left blank, the default CA Dir will be used."
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:60
+#: src/views/preference/AuthSettings.vue:61
 msgid ""
 "If the number of login failed attempts from a ip reach the max attempts in "
 "ban threshold minutes, the ip will be banned for a period of time."
 msgstr ""
 
+#: src/views/preference/components/TOTP.vue:108
+msgid ""
+"If you lose your mobile phone, you can use the recovery code to reset your "
+"2FA."
+msgstr ""
+
 #: src/views/certificate/Certificate.vue:136
 #, fuzzy
 msgid "Import"
@@ -910,7 +942,7 @@ msgstr "Exporter"
 msgid "Import Certificate"
 msgstr "État du certificat"
 
-#: src/views/other/Login.vue:59
+#: src/views/other/Login.vue:71
 #, fuzzy
 msgid "Incorrect username or password"
 msgstr "Le pseudo ou mot de passe est incorect"
@@ -927,7 +959,16 @@ msgstr "Erreur du programme de mise à niveau initial du core"
 msgid "Initialing core upgrader"
 msgstr "Initialisation du programme de mise à niveau du core"
 
-#: src/routes/index.ts:273 src/views/other/Install.vue:135
+#: src/views/preference/components/TOTP.vue:144
+msgid "Input the code from the app:"
+msgstr ""
+
+#: src/components/OTP/OTPAuthorization.vue:49
+#: src/views/preference/components/TOTP.vue:157
+msgid "Input the recovery code:"
+msgstr ""
+
+#: src/routes/index.ts:283 src/views/other/Install.vue:135
 msgid "Install"
 msgstr "Installer"
 
@@ -947,7 +988,15 @@ msgstr ""
 msgid "Invalid"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:14
+#: src/views/other/Login.vue:81
+msgid "Invalid 2FA or recovery code"
+msgstr ""
+
+#: src/components/OTP/useOTPModal.ts:43
+msgid "Invalid passcode or recovery code"
+msgstr ""
+
+#: src/views/preference/AuthSettings.vue:15
 msgid "IP"
 msgstr ""
 
@@ -1043,11 +1092,11 @@ msgstr "Localisations"
 msgid "Log"
 msgstr "Connexion"
 
-#: src/routes/index.ts:279 src/views/other/Login.vue:159
+#: src/routes/index.ts:289 src/views/other/Login.vue:192
 msgid "Login"
 msgstr "Connexion"
 
-#: src/views/other/Login.vue:109 src/views/other/Login.vue:51
+#: src/views/other/Login.vue:125 src/views/other/Login.vue:60
 msgid "Login successful"
 msgstr "Connexion réussie"
 
@@ -1091,7 +1140,7 @@ msgstr "Gérer les sites"
 msgid "Manage Streams"
 msgstr "Gérer les sites"
 
-#: src/routes/index.ts:230 src/views/user/User.vue:50
+#: src/routes/index.ts:235 src/views/user/User.vue:50
 msgid "Manage Users"
 msgstr "Gérer les utilisateurs"
 
@@ -1100,7 +1149,7 @@ msgstr "Gérer les utilisateurs"
 msgid "Managed Certificate"
 msgstr "Changer de certificat"
 
-#: src/views/preference/AuthSettings.vue:74
+#: src/views/preference/AuthSettings.vue:75
 msgid "Max Attempts"
 msgstr ""
 
@@ -1230,7 +1279,7 @@ msgstr "Nginx a redémarré avec succès"
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:90
 #: src/views/domain/ngx_conf/LocationEditor.vue:71
 #: src/views/notification/Notification.vue:70
-#: src/views/preference/AuthSettings.vue:96
+#: src/views/preference/AuthSettings.vue:97
 #: src/views/preference/BasicSettings.vue:101
 #: src/views/stream/StreamList.vue:165
 msgid "No"
@@ -1245,7 +1294,7 @@ msgstr "Secret Jwt"
 msgid "Not After"
 msgstr ""
 
-#: src/routes/index.ts:285
+#: src/routes/index.ts:295
 msgid "Not Found"
 msgstr "Introuvable"
 
@@ -1263,7 +1312,7 @@ msgstr "Note"
 msgid "Notification"
 msgstr "Certification"
 
-#: src/components/Notification/Notification.vue:82 src/routes/index.ts:221
+#: src/components/Notification/Notification.vue:82 src/routes/index.ts:226
 #, fuzzy
 msgid "Notifications"
 msgstr "Certification"
@@ -1344,7 +1393,7 @@ msgstr ""
 msgid "Params"
 msgstr "Paramètres"
 
-#: src/views/other/Login.vue:144 src/views/user/User.vue:18
+#: src/views/other/Login.vue:169 src/views/user/User.vue:18
 msgid "Password"
 msgstr "Mot de passe"
 
@@ -1370,6 +1419,10 @@ msgstr "Erreur lors de la mise a niveau du core"
 msgid "Performing core upgrade"
 msgstr "Exécution de la mise à niveau du core"
 
+#: src/components/OTP/OTPAuthorization.vue:37
+msgid "Please enter the 2FA code:"
+msgstr ""
+
 #: src/views/certificate/DNSCredential.vue:53
 msgid ""
 "Please fill in the API authentication credentials provided by your DNS "
@@ -1404,11 +1457,11 @@ msgstr ""
 msgid "Please input your E-mail!"
 msgstr "Veuillez saisir votre e-mail !"
 
-#: src/views/other/Install.vue:45 src/views/other/Login.vue:39
+#: src/views/other/Install.vue:45 src/views/other/Login.vue:43
 msgid "Please input your password!"
 msgstr "Veuillez saisir votre mot de passe !"
 
-#: src/views/other/Install.vue:39 src/views/other/Login.vue:33
+#: src/views/other/Install.vue:39 src/views/other/Login.vue:37
 msgid "Please input your username!"
 msgstr "Veuillez saisir votre nom d'utilisateur !"
 
@@ -1428,7 +1481,7 @@ msgstr ""
 msgid "Pre-release"
 msgstr ""
 
-#: src/routes/index.ts:239 src/views/preference/Preference.vue:105
+#: src/routes/index.ts:244 src/views/preference/Preference.vue:105
 msgid "Preference"
 msgstr "Préférence"
 
@@ -1474,6 +1527,19 @@ msgstr ""
 msgid "Recovered Successfully"
 msgstr "Enregistré avec succès"
 
+#: src/components/OTP/OTPAuthorization.vue:56
+#: src/views/preference/components/TOTP.vue:167
+msgid "Recovery"
+msgstr ""
+
+#: src/views/preference/components/TOTP.vue:101
+msgid "Recovery Code"
+msgstr ""
+
+#: src/views/preference/components/TOTP.vue:110
+msgid "Recovery Code:"
+msgstr ""
+
 #: src/views/preference/BasicSettings.vue:68
 msgid "Recursive Nameservers"
 msgstr ""
@@ -1527,11 +1593,11 @@ msgstr "Rechargement"
 msgid "Reloading nginx"
 msgstr "Rechargement de nginx"
 
-#: src/views/preference/AuthSettings.vue:101
+#: src/views/preference/AuthSettings.vue:102
 msgid "Remove"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:47
+#: src/views/preference/AuthSettings.vue:48
 #, fuzzy
 msgid "Remove successfully"
 msgstr "Enregistré avec succès"
@@ -1576,6 +1642,11 @@ msgstr ""
 msgid "Reset"
 msgstr "Réinitialiser"
 
+#: src/views/preference/components/TOTP.vue:130
+#, fuzzy
+msgid "Reset 2FA"
+msgstr "Réinitialiser"
+
 #: src/components/NginxControl/NginxControl.vue:93
 msgid "Restart"
 msgstr "Redémarrer"
@@ -1623,6 +1694,10 @@ msgstr "Sauvegarde réussie"
 msgid "Saved successfully"
 msgstr "Enregistré avec succès"
 
+#: src/views/preference/components/TOTP.vue:91
+msgid "Scan the QR code with your mobile phone to add the account to the app."
+msgstr ""
+
 #: src/views/certificate/DNSChallenge.vue:89
 msgid "SDK"
 msgstr ""
@@ -1646,7 +1721,9 @@ msgstr "Envoyer"
 #: src/views/config/ConfigEdit.vue:40 src/views/domain/DomainList.vue:81
 #: src/views/environment/BatchUpgrader.vue:57
 #: src/views/environment/Environment.vue:15 src/views/other/Install.vue:69
-#: src/views/preference/AuthSettings.vue:49
+#: src/views/preference/AuthSettings.vue:50
+#: src/views/preference/components/TOTP.vue:42
+#: src/views/preference/components/TOTP.vue:55
 #: src/views/preference/Preference.vue:78 src/views/stream/StreamList.vue:113
 #: src/views/stream/StreamList.vue:81 src/views/system/Upgrade.vue:42
 msgid "Server error"
@@ -1722,7 +1799,7 @@ msgstr "Chemin de la clé du certificat SSL"
 msgid "SSL Certificate Path"
 msgstr "Chemin du certificat SSL"
 
-#: src/views/other/Login.vue:170
+#: src/views/other/Login.vue:203
 #, fuzzy
 msgid "SSO Login"
 msgstr "Connexion"
@@ -1808,7 +1885,7 @@ msgstr "Changer de certificat"
 msgid "Sync to"
 msgstr ""
 
-#: src/routes/index.ts:248
+#: src/routes/index.ts:253
 msgid "System"
 msgstr "Système"
 
@@ -1817,7 +1894,7 @@ msgstr "Système"
 msgid "Target"
 msgstr ""
 
-#: src/routes/index.ts:172 src/views/pty/Terminal.vue:91
+#: src/routes/index.ts:172 src/views/pty/Terminal.vue:114
 msgid "Terminal"
 msgstr "Terminal"
 
@@ -1862,6 +1939,11 @@ msgstr "Chemin de la clé du certificat SSL"
 msgid "The path exists, but the file is not a private key"
 msgstr ""
 
+#: src/views/preference/components/TOTP.vue:109
+msgid ""
+"The recovery code is only displayed once, please save it in a safe place."
+msgstr ""
+
 #: src/views/dashboard/Environments.vue:148
 msgid ""
 "The remote Nginx UI version is not compatible with the local Nginx UI "
@@ -1925,7 +2007,7 @@ msgid ""
 "This will upgrade or reinstall the Nginx UI on %{nodeNames} to %{version}."
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:59
+#: src/views/preference/AuthSettings.vue:60
 #: src/views/preference/LogrotateSettings.vue:12
 msgid "Tips"
 msgstr ""
@@ -1934,6 +2016,12 @@ msgstr ""
 msgid "Title"
 msgstr ""
 
+#: src/views/preference/components/TOTP.vue:90
+msgid ""
+"To enable it, you need to install the Google or Microsoft Authenticator app "
+"on your mobile phone."
+msgstr ""
+
 #: src/views/domain/ngx_conf/NgxConfigEditor.vue:44
 msgid ""
 "To make sure the certification auto-renewal can work normally, we need to "
@@ -1950,14 +2038,24 @@ msgstr ""
 msgid "Token is not valid"
 msgstr ""
 
-#: src/views/other/Login.vue:62
+#: src/views/other/Login.vue:74
 msgid "Too many login failed attempts, please try again later"
 msgstr ""
 
+#: src/views/preference/components/TOTP.vue:89
+msgid ""
+"TOTP is a two-factor authentication method that uses a time-based one-time "
+"password algorithm."
+msgstr ""
+
 #: src/components/StdDesign/StdDataDisplay/StdCurd.vue:209
 msgid "Trash"
 msgstr ""
 
+#: src/components/OTP/useOTPModal.ts:49
+msgid "Two-factor authentication required"
+msgstr ""
+
 #: src/views/certificate/Certificate.vue:37 src/views/config/config.ts:12
 #: src/views/notification/Notification.vue:13
 msgid "Type"
@@ -1977,7 +2075,7 @@ msgstr "Mis à jour le"
 msgid "Updated successfully"
 msgstr "Mis à jour avec succés"
 
-#: src/routes/index.ts:263 src/views/environment/Environment.vue:50
+#: src/routes/index.ts:268 src/views/environment/Environment.vue:50
 #: src/views/system/Upgrade.vue:145 src/views/system/Upgrade.vue:228
 msgid "Upgrade"
 msgstr "Mettre à niveau"
@@ -2007,16 +2105,24 @@ msgstr "Disponibilité :"
 msgid "URL"
 msgstr ""
 
+#: src/components/OTP/OTPAuthorization.vue:69
+msgid "Use OTP"
+msgstr ""
+
+#: src/components/OTP/OTPAuthorization.vue:65
+msgid "Use recovery code"
+msgstr ""
+
 #: src/components/ChatGPT/ChatGPT.vue:229
 #, fuzzy
 msgid "User"
 msgstr "Nom d'utilisateur"
 
-#: src/views/other/Login.vue:65
+#: src/views/other/Login.vue:77
 msgid "User is banned"
 msgstr ""
 
-#: src/views/other/Login.vue:134 src/views/user/User.vue:9
+#: src/views/other/Login.vue:159 src/views/user/User.vue:9
 msgid "Username"
 msgstr "Nom d'utilisateur"
 
@@ -2088,7 +2194,7 @@ msgstr "Écriture du certificat sur le disque"
 
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:89
 #: src/views/domain/ngx_conf/LocationEditor.vue:70
-#: src/views/preference/AuthSettings.vue:95
+#: src/views/preference/AuthSettings.vue:96
 #: src/views/preference/BasicSettings.vue:100
 msgid "Yes"
 msgstr "Oui"

+ 140 - 34
app/src/language/ko_KR/app.po

@@ -13,7 +13,11 @@ msgstr ""
 "Plural-Forms: nplurals=2; plural=(n != 1);\n"
 "X-Generator: Weblate 5.3.1\n"
 
-#: src/routes/index.ts:256
+#: src/views/preference/components/TOTP.vue:88
+msgid "2FA Settings"
+msgstr ""
+
+#: src/routes/index.ts:261
 msgid "About"
 msgstr "소개"
 
@@ -32,7 +36,7 @@ msgstr "사용자 이름"
 #: src/views/certificate/DNSCredential.vue:33 src/views/config/config.ts:34
 #: src/views/domain/DomainList.vue:47 src/views/environment/envColumns.tsx:131
 #: src/views/notification/Notification.vue:37
-#: src/views/preference/AuthSettings.vue:26 src/views/stream/StreamList.vue:47
+#: src/views/preference/AuthSettings.vue:27 src/views/stream/StreamList.vue:47
 #: src/views/user/User.vue:43
 msgid "Action"
 msgstr "작업"
@@ -97,7 +101,7 @@ msgstr "API 토큰"
 msgid "Arch"
 msgstr "아키텍처"
 
-#: src/views/preference/AuthSettings.vue:94
+#: src/views/preference/AuthSettings.vue:95
 #, fuzzy
 msgid "Are you sure to delete this banned IP immediately?"
 msgstr "정말 삭제하시겠습니까?"
@@ -151,7 +155,7 @@ msgstr "ChatGPT에게 도움 요청"
 msgid "Assistant"
 msgstr "조수"
 
-#: src/views/preference/AuthSettings.vue:17
+#: src/views/preference/AuthSettings.vue:18
 msgid "Attempts"
 msgstr ""
 
@@ -196,15 +200,15 @@ msgstr "홈으로"
 msgid "Back to list"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:68
+#: src/views/preference/AuthSettings.vue:69
 msgid "Ban Threshold Minutes"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:82
+#: src/views/preference/AuthSettings.vue:83
 msgid "Banned IPs"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:20
+#: src/views/preference/AuthSettings.vue:21
 msgid "Banned Until"
 msgstr ""
 
@@ -353,6 +357,10 @@ msgstr "SSL 구성하기"
 msgid "Connected"
 msgstr "연결됨"
 
+#: src/views/pty/Terminal.vue:120
+msgid "Connection lost, please refresh the page."
+msgstr ""
+
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:111
 #: src/views/domain/ngx_conf/LocationEditor.vue:125
 #: src/views/domain/ngx_conf/LocationEditor.vue:97
@@ -395,6 +403,14 @@ msgstr "인증 정보"
 msgid "Credentials"
 msgstr "인증 정보들"
 
+#: src/views/preference/components/TOTP.vue:96
+msgid "Current account is enabled 2FA."
+msgstr ""
+
+#: src/views/preference/components/TOTP.vue:93
+msgid "Current account is not enabled 2FA."
+msgstr ""
+
 #: src/views/system/Upgrade.vue:167
 msgid "Current Version"
 msgstr "현재 버전"
@@ -666,6 +682,16 @@ msgstr "%{node_name}에서 %{conf_name} 활성화 실패"
 msgid "Enable %{conf_name} in %{node_name} successfully"
 msgstr "%{node_name}에서 %{conf_name} 성공적으로 활성화됨"
 
+#: src/views/preference/components/TOTP.vue:122
+#, fuzzy
+msgid "Enable 2FA"
+msgstr "활성화"
+
+#: src/views/preference/components/TOTP.vue:52
+#, fuzzy
+msgid "Enable 2FA successfully"
+msgstr "성공적으로 활성화"
+
 #: src/views/domain/cert/components/ObtainCert.vue:70
 msgid "Enable auto-renewal failed for %{name}"
 msgstr "%{name}에 대한 자동 갱신 활성화 실패"
@@ -866,12 +892,18 @@ msgstr "HTTP01"
 msgid "If left blank, the default CA Dir will be used."
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:60
+#: src/views/preference/AuthSettings.vue:61
 msgid ""
 "If the number of login failed attempts from a ip reach the max attempts in "
 "ban threshold minutes, the ip will be banned for a period of time."
 msgstr ""
 
+#: src/views/preference/components/TOTP.vue:108
+msgid ""
+"If you lose your mobile phone, you can use the recovery code to reset your "
+"2FA."
+msgstr ""
+
 #: src/views/certificate/Certificate.vue:136
 msgid "Import"
 msgstr "가져오기"
@@ -881,7 +913,7 @@ msgstr "가져오기"
 msgid "Import Certificate"
 msgstr "인증서 상태"
 
-#: src/views/other/Login.vue:59
+#: src/views/other/Login.vue:71
 #, fuzzy
 msgid "Incorrect username or password"
 msgstr "사용자 이름 또는 비밀번호가 올바르지 않습니다"
@@ -898,7 +930,16 @@ msgstr "초기 코어 업그레이더 오류"
 msgid "Initialing core upgrader"
 msgstr "코어 업그레이더 초기화"
 
-#: src/routes/index.ts:273 src/views/other/Install.vue:135
+#: src/views/preference/components/TOTP.vue:144
+msgid "Input the code from the app:"
+msgstr ""
+
+#: src/components/OTP/OTPAuthorization.vue:49
+#: src/views/preference/components/TOTP.vue:157
+msgid "Input the recovery code:"
+msgstr ""
+
+#: src/routes/index.ts:283 src/views/other/Install.vue:135
 msgid "Install"
 msgstr "설치"
 
@@ -920,7 +961,15 @@ msgstr "간격"
 msgid "Invalid"
 msgstr "유효함"
 
-#: src/views/preference/AuthSettings.vue:14
+#: src/views/other/Login.vue:81
+msgid "Invalid 2FA or recovery code"
+msgstr ""
+
+#: src/components/OTP/useOTPModal.ts:43
+msgid "Invalid passcode or recovery code"
+msgstr ""
+
+#: src/views/preference/AuthSettings.vue:15
 msgid "IP"
 msgstr ""
 
@@ -1014,11 +1063,11 @@ msgstr "위치들"
 msgid "Log"
 msgstr "로그인"
 
-#: src/routes/index.ts:279 src/views/other/Login.vue:159
+#: src/routes/index.ts:289 src/views/other/Login.vue:192
 msgid "Login"
 msgstr "로그인"
 
-#: src/views/other/Login.vue:109 src/views/other/Login.vue:51
+#: src/views/other/Login.vue:125 src/views/other/Login.vue:60
 msgid "Login successful"
 msgstr "로그인 성공"
 
@@ -1067,7 +1116,7 @@ msgstr "사이트 관리"
 msgid "Manage Streams"
 msgstr "스트림 관리"
 
-#: src/routes/index.ts:230 src/views/user/User.vue:50
+#: src/routes/index.ts:235 src/views/user/User.vue:50
 msgid "Manage Users"
 msgstr "사용자 관리"
 
@@ -1076,7 +1125,7 @@ msgstr "사용자 관리"
 msgid "Managed Certificate"
 msgstr "인증서 유효"
 
-#: src/views/preference/AuthSettings.vue:74
+#: src/views/preference/AuthSettings.vue:75
 msgid "Max Attempts"
 msgstr ""
 
@@ -1210,7 +1259,7 @@ msgstr "Nginx가 성공적으로 재시작됨"
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:90
 #: src/views/domain/ngx_conf/LocationEditor.vue:71
 #: src/views/notification/Notification.vue:70
-#: src/views/preference/AuthSettings.vue:96
+#: src/views/preference/AuthSettings.vue:97
 #: src/views/preference/BasicSettings.vue:101
 #: src/views/stream/StreamList.vue:165
 msgid "No"
@@ -1224,7 +1273,7 @@ msgstr "노드 시크릿"
 msgid "Not After"
 msgstr "만료일"
 
-#: src/routes/index.ts:285
+#: src/routes/index.ts:295
 msgid "Not Found"
 msgstr "찾을 수 없음"
 
@@ -1242,7 +1291,7 @@ msgstr "참고"
 msgid "Notification"
 msgstr "알림"
 
-#: src/components/Notification/Notification.vue:82 src/routes/index.ts:221
+#: src/components/Notification/Notification.vue:82 src/routes/index.ts:226
 #, fuzzy
 msgid "Notifications"
 msgstr "알림"
@@ -1325,7 +1374,7 @@ msgstr "기존 파일 덮어쓰기"
 msgid "Params"
 msgstr "파라미터"
 
-#: src/views/other/Login.vue:144 src/views/user/User.vue:18
+#: src/views/other/Login.vue:169 src/views/user/User.vue:18
 msgid "Password"
 msgstr "비밀번호"
 
@@ -1351,6 +1400,10 @@ msgstr "핵심 업그레이드 오류 수행"
 msgid "Performing core upgrade"
 msgstr "핵심 업그레이드 수행 중"
 
+#: src/components/OTP/OTPAuthorization.vue:37
+msgid "Please enter the 2FA code:"
+msgstr ""
+
 #: src/views/certificate/DNSCredential.vue:53
 msgid ""
 "Please fill in the API authentication credentials provided by your DNS "
@@ -1380,11 +1433,11 @@ msgstr "이름을 입력해주세요, 이것은 새 구성의 파일 이름으
 msgid "Please input your E-mail!"
 msgstr "이메일을 입력해주세요!"
 
-#: src/views/other/Install.vue:45 src/views/other/Login.vue:39
+#: src/views/other/Install.vue:45 src/views/other/Login.vue:43
 msgid "Please input your password!"
 msgstr "비밀번호를 입력해주세요!"
 
-#: src/views/other/Install.vue:39 src/views/other/Login.vue:33
+#: src/views/other/Install.vue:39 src/views/other/Login.vue:37
 msgid "Please input your username!"
 msgstr "사용자 이름을 입력해주세요!"
 
@@ -1404,7 +1457,7 @@ msgstr "적어도 하나의 노드를 선택해주세요!"
 msgid "Pre-release"
 msgstr "사전 출시"
 
-#: src/routes/index.ts:239 src/views/preference/Preference.vue:105
+#: src/routes/index.ts:244 src/views/preference/Preference.vue:105
 msgid "Preference"
 msgstr "환경설정"
 
@@ -1448,6 +1501,19 @@ msgstr ""
 msgid "Recovered Successfully"
 msgstr "성공적으로 제거됨"
 
+#: src/components/OTP/OTPAuthorization.vue:56
+#: src/views/preference/components/TOTP.vue:167
+msgid "Recovery"
+msgstr ""
+
+#: src/views/preference/components/TOTP.vue:101
+msgid "Recovery Code"
+msgstr ""
+
+#: src/views/preference/components/TOTP.vue:110
+msgid "Recovery Code:"
+msgstr ""
+
 #: src/views/preference/BasicSettings.vue:68
 msgid "Recursive Nameservers"
 msgstr ""
@@ -1502,11 +1568,11 @@ msgstr "리로딩 중"
 msgid "Reloading nginx"
 msgstr "Nginx 리로딩 중"
 
-#: src/views/preference/AuthSettings.vue:101
+#: src/views/preference/AuthSettings.vue:102
 msgid "Remove"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:47
+#: src/views/preference/AuthSettings.vue:48
 #, fuzzy
 msgid "Remove successfully"
 msgstr "성공적으로 제거됨"
@@ -1551,6 +1617,11 @@ msgstr "잘못된 매개변수로 요청됨"
 msgid "Reset"
 msgstr "재설정"
 
+#: src/views/preference/components/TOTP.vue:130
+#, fuzzy
+msgid "Reset 2FA"
+msgstr "재설정"
+
 #: src/components/NginxControl/NginxControl.vue:93
 msgid "Restart"
 msgstr "재시작"
@@ -1600,6 +1671,10 @@ msgstr "성공적으로 저장됨"
 msgid "Saved successfully"
 msgstr "성공적으로 저장됨"
 
+#: src/views/preference/components/TOTP.vue:91
+msgid "Scan the QR code with your mobile phone to add the account to the app."
+msgstr ""
+
 #: src/views/certificate/DNSChallenge.vue:89
 msgid "SDK"
 msgstr ""
@@ -1623,7 +1698,9 @@ msgstr "보내기"
 #: src/views/config/ConfigEdit.vue:40 src/views/domain/DomainList.vue:81
 #: src/views/environment/BatchUpgrader.vue:57
 #: src/views/environment/Environment.vue:15 src/views/other/Install.vue:69
-#: src/views/preference/AuthSettings.vue:49
+#: src/views/preference/AuthSettings.vue:50
+#: src/views/preference/components/TOTP.vue:42
+#: src/views/preference/components/TOTP.vue:55
 #: src/views/preference/Preference.vue:78 src/views/stream/StreamList.vue:113
 #: src/views/stream/StreamList.vue:81 src/views/system/Upgrade.vue:42
 msgid "Server error"
@@ -1699,7 +1776,7 @@ msgstr "SSL 인증서 키 경로"
 msgid "SSL Certificate Path"
 msgstr "SSL 인증서 경로"
 
-#: src/views/other/Login.vue:170
+#: src/views/other/Login.vue:203
 #, fuzzy
 msgid "SSO Login"
 msgstr "SSO 로그인"
@@ -1784,7 +1861,7 @@ msgstr "인증서 갱신 성공"
 msgid "Sync to"
 msgstr ""
 
-#: src/routes/index.ts:248
+#: src/routes/index.ts:253
 msgid "System"
 msgstr "시스템"
 
@@ -1793,7 +1870,7 @@ msgstr "시스템"
 msgid "Target"
 msgstr "대상"
 
-#: src/routes/index.ts:172 src/views/pty/Terminal.vue:91
+#: src/routes/index.ts:172 src/views/pty/Terminal.vue:114
 msgid "Terminal"
 msgstr "터미널"
 
@@ -1838,6 +1915,11 @@ msgstr "Certificate Status"
 msgid "The path exists, but the file is not a private key"
 msgstr "경로는 존재하지만 파일은 개인 키가 아닙니다"
 
+#: src/views/preference/components/TOTP.vue:109
+msgid ""
+"The recovery code is only displayed once, please save it in a safe place."
+msgstr ""
+
 #: src/views/dashboard/Environments.vue:148
 msgid ""
 "The remote Nginx UI version is not compatible with the local Nginx UI "
@@ -1899,7 +1981,7 @@ msgid ""
 "This will upgrade or reinstall the Nginx UI on %{nodeNames} to %{version}."
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:59
+#: src/views/preference/AuthSettings.vue:60
 #: src/views/preference/LogrotateSettings.vue:12
 msgid "Tips"
 msgstr "팁"
@@ -1908,6 +1990,12 @@ msgstr "팁"
 msgid "Title"
 msgstr "제목"
 
+#: src/views/preference/components/TOTP.vue:90
+msgid ""
+"To enable it, you need to install the Google or Microsoft Authenticator app "
+"on your mobile phone."
+msgstr ""
+
 #: src/views/domain/ngx_conf/NgxConfigEditor.vue:44
 msgid ""
 "To make sure the certification auto-renewal can work normally, we need to "
@@ -1923,14 +2011,24 @@ msgstr ""
 msgid "Token is not valid"
 msgstr "토큰이 유효하지 않습니다"
 
-#: src/views/other/Login.vue:62
+#: src/views/other/Login.vue:74
 msgid "Too many login failed attempts, please try again later"
 msgstr ""
 
+#: src/views/preference/components/TOTP.vue:89
+msgid ""
+"TOTP is a two-factor authentication method that uses a time-based one-time "
+"password algorithm."
+msgstr ""
+
 #: src/components/StdDesign/StdDataDisplay/StdCurd.vue:209
 msgid "Trash"
 msgstr ""
 
+#: src/components/OTP/useOTPModal.ts:49
+msgid "Two-factor authentication required"
+msgstr ""
+
 #: src/views/certificate/Certificate.vue:37 src/views/config/config.ts:12
 #: src/views/notification/Notification.vue:13
 msgid "Type"
@@ -1951,7 +2049,7 @@ msgstr "업데이트됨"
 msgid "Updated successfully"
 msgstr "성공적으로 저장되었습니다"
 
-#: src/routes/index.ts:263 src/views/environment/Environment.vue:50
+#: src/routes/index.ts:268 src/views/environment/Environment.vue:50
 #: src/views/system/Upgrade.vue:145 src/views/system/Upgrade.vue:228
 msgid "Upgrade"
 msgstr "업그레이드"
@@ -1982,16 +2080,24 @@ msgstr "가동 시간:"
 msgid "URL"
 msgstr "URL"
 
+#: src/components/OTP/OTPAuthorization.vue:69
+msgid "Use OTP"
+msgstr ""
+
+#: src/components/OTP/OTPAuthorization.vue:65
+msgid "Use recovery code"
+msgstr ""
+
 #: src/components/ChatGPT/ChatGPT.vue:229
 #, fuzzy
 msgid "User"
 msgstr "사용자 이름"
 
-#: src/views/other/Login.vue:65
+#: src/views/other/Login.vue:77
 msgid "User is banned"
 msgstr ""
 
-#: src/views/other/Login.vue:134 src/views/user/User.vue:9
+#: src/views/other/Login.vue:159 src/views/user/User.vue:9
 msgid "Username"
 msgstr "사용자 이름"
 
@@ -2066,7 +2172,7 @@ msgstr "인증서를 디스크에 쓰기"
 
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:89
 #: src/views/domain/ngx_conf/LocationEditor.vue:70
-#: src/views/preference/AuthSettings.vue:95
+#: src/views/preference/AuthSettings.vue:96
 #: src/views/preference/BasicSettings.vue:100
 msgid "Yes"
 msgstr "예"

+ 132 - 36
app/src/language/messages.pot

@@ -2,7 +2,11 @@ msgid ""
 msgstr ""
 "Content-Type: text/plain; charset=UTF-8\n"
 
-#: src/routes/index.ts:256
+#: src/views/preference/components/TOTP.vue:88
+msgid "2FA Settings"
+msgstr ""
+
+#: src/routes/index.ts:261
 msgid "About"
 msgstr ""
 
@@ -24,7 +28,7 @@ msgstr ""
 #: src/views/domain/DomainList.vue:47
 #: src/views/environment/envColumns.tsx:131
 #: src/views/notification/Notification.vue:37
-#: src/views/preference/AuthSettings.vue:26
+#: src/views/preference/AuthSettings.vue:27
 #: src/views/stream/StreamList.vue:47
 #: src/views/user/User.vue:43
 msgid "Action"
@@ -91,7 +95,7 @@ msgstr ""
 msgid "Arch"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:94
+#: src/views/preference/AuthSettings.vue:95
 msgid "Are you sure to delete this banned IP immediately?"
 msgstr ""
 
@@ -141,7 +145,7 @@ msgstr ""
 msgid "Assistant"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:17
+#: src/views/preference/AuthSettings.vue:18
 msgid "Attempts"
 msgstr ""
 
@@ -187,15 +191,15 @@ msgstr ""
 msgid "Back to list"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:68
+#: src/views/preference/AuthSettings.vue:69
 msgid "Ban Threshold Minutes"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:82
+#: src/views/preference/AuthSettings.vue:83
 msgid "Banned IPs"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:20
+#: src/views/preference/AuthSettings.vue:21
 msgid "Banned Until"
 msgstr ""
 
@@ -345,6 +349,10 @@ msgstr ""
 msgid "Connected"
 msgstr ""
 
+#: src/views/pty/Terminal.vue:120
+msgid "Connection lost, please refresh the page."
+msgstr ""
+
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:111
 #: src/views/domain/ngx_conf/LocationEditor.vue:125
 #: src/views/domain/ngx_conf/LocationEditor.vue:97
@@ -388,6 +396,14 @@ msgstr ""
 msgid "Credentials"
 msgstr ""
 
+#: src/views/preference/components/TOTP.vue:96
+msgid "Current account is enabled 2FA."
+msgstr ""
+
+#: src/views/preference/components/TOTP.vue:93
+msgid "Current account is not enabled 2FA."
+msgstr ""
+
 #: src/views/system/Upgrade.vue:167
 msgid "Current Version"
 msgstr ""
@@ -662,6 +678,14 @@ msgstr ""
 msgid "Enable %{conf_name} in %{node_name} successfully"
 msgstr ""
 
+#: src/views/preference/components/TOTP.vue:122
+msgid "Enable 2FA"
+msgstr ""
+
+#: src/views/preference/components/TOTP.vue:52
+msgid "Enable 2FA successfully"
+msgstr ""
+
 #: src/views/domain/cert/components/ObtainCert.vue:70
 msgid "Enable auto-renewal failed for %{name}"
 msgstr ""
@@ -863,10 +887,14 @@ msgstr ""
 msgid "If left blank, the default CA Dir will be used."
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:60
+#: src/views/preference/AuthSettings.vue:61
 msgid "If the number of login failed attempts from a ip reach the max attempts in ban threshold minutes, the ip will be banned for a period of time."
 msgstr ""
 
+#: src/views/preference/components/TOTP.vue:108
+msgid "If you lose your mobile phone, you can use the recovery code to reset your 2FA."
+msgstr ""
+
 #: src/views/certificate/Certificate.vue:136
 msgid "Import"
 msgstr ""
@@ -876,7 +904,7 @@ msgstr ""
 msgid "Import Certificate"
 msgstr ""
 
-#: src/views/other/Login.vue:59
+#: src/views/other/Login.vue:71
 msgid "Incorrect username or password"
 msgstr ""
 
@@ -892,7 +920,16 @@ msgstr ""
 msgid "Initialing core upgrader"
 msgstr ""
 
-#: src/routes/index.ts:273
+#: src/views/preference/components/TOTP.vue:144
+msgid "Input the code from the app:"
+msgstr ""
+
+#: src/components/OTP/OTPAuthorization.vue:49
+#: src/views/preference/components/TOTP.vue:157
+msgid "Input the recovery code:"
+msgstr ""
+
+#: src/routes/index.ts:283
 #: src/views/other/Install.vue:135
 msgid "Install"
 msgstr ""
@@ -913,7 +950,15 @@ msgstr ""
 msgid "Invalid"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:14
+#: src/views/other/Login.vue:81
+msgid "Invalid 2FA or recovery code"
+msgstr ""
+
+#: src/components/OTP/useOTPModal.ts:43
+msgid "Invalid passcode or recovery code"
+msgstr ""
+
+#: src/views/preference/AuthSettings.vue:15
 msgid "IP"
 msgstr ""
 
@@ -998,13 +1043,13 @@ msgstr ""
 msgid "Log"
 msgstr ""
 
-#: src/routes/index.ts:279
-#: src/views/other/Login.vue:159
+#: src/routes/index.ts:289
+#: src/views/other/Login.vue:192
 msgid "Login"
 msgstr ""
 
-#: src/views/other/Login.vue:109
-#: src/views/other/Login.vue:51
+#: src/views/other/Login.vue:125
+#: src/views/other/Login.vue:60
 msgid "Login successful"
 msgstr ""
 
@@ -1038,7 +1083,7 @@ msgstr ""
 msgid "Manage Streams"
 msgstr ""
 
-#: src/routes/index.ts:230
+#: src/routes/index.ts:235
 #: src/views/user/User.vue:50
 msgid "Manage Users"
 msgstr ""
@@ -1047,7 +1092,7 @@ msgstr ""
 msgid "Managed Certificate"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:74
+#: src/views/preference/AuthSettings.vue:75
 msgid "Max Attempts"
 msgstr ""
 
@@ -1178,7 +1223,7 @@ msgstr ""
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:90
 #: src/views/domain/ngx_conf/LocationEditor.vue:71
 #: src/views/notification/Notification.vue:70
-#: src/views/preference/AuthSettings.vue:96
+#: src/views/preference/AuthSettings.vue:97
 #: src/views/preference/BasicSettings.vue:101
 #: src/views/stream/StreamList.vue:165
 msgid "No"
@@ -1192,7 +1237,7 @@ msgstr ""
 msgid "Not After"
 msgstr ""
 
-#: src/routes/index.ts:285
+#: src/routes/index.ts:295
 msgid "Not Found"
 msgstr ""
 
@@ -1210,7 +1255,7 @@ msgid "Notification"
 msgstr ""
 
 #: src/components/Notification/Notification.vue:82
-#: src/routes/index.ts:221
+#: src/routes/index.ts:226
 msgid "Notifications"
 msgstr ""
 
@@ -1290,7 +1335,7 @@ msgstr ""
 msgid "Params"
 msgstr ""
 
-#: src/views/other/Login.vue:144
+#: src/views/other/Login.vue:169
 #: src/views/user/User.vue:18
 msgid "Password"
 msgstr ""
@@ -1317,6 +1362,10 @@ msgstr ""
 msgid "Performing core upgrade"
 msgstr ""
 
+#: src/components/OTP/OTPAuthorization.vue:37
+msgid "Please enter the 2FA code:"
+msgstr ""
+
 #: src/views/certificate/DNSCredential.vue:53
 msgid "Please fill in the API authentication credentials provided by your DNS provider."
 msgstr ""
@@ -1339,12 +1388,12 @@ msgid "Please input your E-mail!"
 msgstr ""
 
 #: src/views/other/Install.vue:45
-#: src/views/other/Login.vue:39
+#: src/views/other/Login.vue:43
 msgid "Please input your password!"
 msgstr ""
 
 #: src/views/other/Install.vue:39
-#: src/views/other/Login.vue:33
+#: src/views/other/Login.vue:37
 msgid "Please input your username!"
 msgstr ""
 
@@ -1364,7 +1413,7 @@ msgstr ""
 msgid "Pre-release"
 msgstr ""
 
-#: src/routes/index.ts:239
+#: src/routes/index.ts:244
 #: src/views/preference/Preference.vue:105
 msgid "Preference"
 msgstr ""
@@ -1407,6 +1456,19 @@ msgstr ""
 msgid "Recovered Successfully"
 msgstr ""
 
+#: src/components/OTP/OTPAuthorization.vue:56
+#: src/views/preference/components/TOTP.vue:167
+msgid "Recovery"
+msgstr ""
+
+#: src/views/preference/components/TOTP.vue:101
+msgid "Recovery Code"
+msgstr ""
+
+#: src/views/preference/components/TOTP.vue:110
+msgid "Recovery Code:"
+msgstr ""
+
 #: src/views/preference/BasicSettings.vue:68
 msgid "Recursive Nameservers"
 msgstr ""
@@ -1456,11 +1518,11 @@ msgstr ""
 msgid "Reloading nginx"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:101
+#: src/views/preference/AuthSettings.vue:102
 msgid "Remove"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:47
+#: src/views/preference/AuthSettings.vue:48
 msgid "Remove successfully"
 msgstr ""
 
@@ -1498,6 +1560,10 @@ msgstr ""
 msgid "Reset"
 msgstr ""
 
+#: src/views/preference/components/TOTP.vue:130
+msgid "Reset 2FA"
+msgstr ""
+
 #: src/components/NginxControl/NginxControl.vue:93
 msgid "Restart"
 msgstr ""
@@ -1549,6 +1615,10 @@ msgstr ""
 msgid "Saved successfully"
 msgstr ""
 
+#: src/views/preference/components/TOTP.vue:91
+msgid "Scan the QR code with your mobile phone to add the account to the app."
+msgstr ""
+
 #: src/views/certificate/DNSChallenge.vue:89
 msgid "SDK"
 msgstr ""
@@ -1574,7 +1644,9 @@ msgstr ""
 #: src/views/environment/BatchUpgrader.vue:57
 #: src/views/environment/Environment.vue:15
 #: src/views/other/Install.vue:69
-#: src/views/preference/AuthSettings.vue:49
+#: src/views/preference/AuthSettings.vue:50
+#: src/views/preference/components/TOTP.vue:42
+#: src/views/preference/components/TOTP.vue:55
 #: src/views/preference/Preference.vue:78
 #: src/views/stream/StreamList.vue:113
 #: src/views/stream/StreamList.vue:81
@@ -1645,7 +1717,7 @@ msgstr ""
 msgid "SSL Certificate Path"
 msgstr ""
 
-#: src/views/other/Login.vue:170
+#: src/views/other/Login.vue:203
 msgid "SSO Login"
 msgstr ""
 
@@ -1722,7 +1794,7 @@ msgstr ""
 msgid "Sync to"
 msgstr ""
 
-#: src/routes/index.ts:248
+#: src/routes/index.ts:253
 msgid "System"
 msgstr ""
 
@@ -1732,7 +1804,7 @@ msgid "Target"
 msgstr ""
 
 #: src/routes/index.ts:172
-#: src/views/pty/Terminal.vue:91
+#: src/views/pty/Terminal.vue:114
 msgid "Terminal"
 msgstr ""
 
@@ -1768,6 +1840,10 @@ msgstr ""
 msgid "The path exists, but the file is not a private key"
 msgstr ""
 
+#: src/views/preference/components/TOTP.vue:109
+msgid "The recovery code is only displayed once, please save it in a safe place."
+msgstr ""
+
 #: src/views/dashboard/Environments.vue:148
 msgid "The remote Nginx UI version is not compatible with the local Nginx UI version. To avoid potential errors, please upgrade the remote Nginx UI to match the local version."
 msgstr ""
@@ -1816,7 +1892,7 @@ msgstr ""
 msgid "This will upgrade or reinstall the Nginx UI on %{nodeNames} to %{version}."
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:59
+#: src/views/preference/AuthSettings.vue:60
 #: src/views/preference/LogrotateSettings.vue:12
 msgid "Tips"
 msgstr ""
@@ -1825,6 +1901,10 @@ msgstr ""
 msgid "Title"
 msgstr ""
 
+#: src/views/preference/components/TOTP.vue:90
+msgid "To enable it, you need to install the Google or Microsoft Authenticator app on your mobile phone."
+msgstr ""
+
 #: src/views/domain/ngx_conf/NgxConfigEditor.vue:44
 msgid "To make sure the certification auto-renewal can work normally, we need to add a location which can proxy the request from authority to backend, and we need to save this file and reload the Nginx. Are you sure you want to continue?"
 msgstr ""
@@ -1833,14 +1913,22 @@ msgstr ""
 msgid "Token is not valid"
 msgstr ""
 
-#: src/views/other/Login.vue:62
+#: src/views/other/Login.vue:74
 msgid "Too many login failed attempts, please try again later"
 msgstr ""
 
+#: src/views/preference/components/TOTP.vue:89
+msgid "TOTP is a two-factor authentication method that uses a time-based one-time password algorithm."
+msgstr ""
+
 #: src/components/StdDesign/StdDataDisplay/StdCurd.vue:209
 msgid "Trash"
 msgstr ""
 
+#: src/components/OTP/useOTPModal.ts:49
+msgid "Two-factor authentication required"
+msgstr ""
+
 #: src/views/certificate/Certificate.vue:37
 #: src/views/config/config.ts:12
 #: src/views/notification/Notification.vue:13
@@ -1864,7 +1952,7 @@ msgstr ""
 msgid "Updated successfully"
 msgstr ""
 
-#: src/routes/index.ts:263
+#: src/routes/index.ts:268
 #: src/views/environment/Environment.vue:50
 #: src/views/system/Upgrade.vue:145
 #: src/views/system/Upgrade.vue:228
@@ -1896,15 +1984,23 @@ msgstr ""
 msgid "URL"
 msgstr ""
 
+#: src/components/OTP/OTPAuthorization.vue:69
+msgid "Use OTP"
+msgstr ""
+
+#: src/components/OTP/OTPAuthorization.vue:65
+msgid "Use recovery code"
+msgstr ""
+
 #: src/components/ChatGPT/ChatGPT.vue:229
 msgid "User"
 msgstr ""
 
-#: src/views/other/Login.vue:65
+#: src/views/other/Login.vue:77
 msgid "User is banned"
 msgstr ""
 
-#: src/views/other/Login.vue:134
+#: src/views/other/Login.vue:159
 #: src/views/user/User.vue:9
 msgid "Username"
 msgstr ""
@@ -1969,7 +2065,7 @@ msgstr ""
 
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:89
 #: src/views/domain/ngx_conf/LocationEditor.vue:70
-#: src/views/preference/AuthSettings.vue:95
+#: src/views/preference/AuthSettings.vue:96
 #: src/views/preference/BasicSettings.vue:100
 msgid "Yes"
 msgstr ""

+ 140 - 34
app/src/language/ru_RU/app.po

@@ -9,7 +9,11 @@ msgstr ""
 "Content-Transfer-Encoding: 8bit\n"
 "Plural-Forms: nplurals=2; plural=(n != 1);\n"
 
-#: src/routes/index.ts:256
+#: src/views/preference/components/TOTP.vue:88
+msgid "2FA Settings"
+msgstr ""
+
+#: src/routes/index.ts:261
 msgid "About"
 msgstr "О проекте"
 
@@ -28,7 +32,7 @@ msgstr "Пользователь"
 #: src/views/certificate/DNSCredential.vue:33 src/views/config/config.ts:34
 #: src/views/domain/DomainList.vue:47 src/views/environment/envColumns.tsx:131
 #: src/views/notification/Notification.vue:37
-#: src/views/preference/AuthSettings.vue:26 src/views/stream/StreamList.vue:47
+#: src/views/preference/AuthSettings.vue:27 src/views/stream/StreamList.vue:47
 #: src/views/user/User.vue:43
 msgid "Action"
 msgstr "Действие"
@@ -95,7 +99,7 @@ msgstr ""
 msgid "Arch"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:94
+#: src/views/preference/AuthSettings.vue:95
 #, fuzzy
 msgid "Are you sure to delete this banned IP immediately?"
 msgstr "Вы уверены, что хотите удалить?"
@@ -153,7 +157,7 @@ msgstr "Обратитесь за помощью к ChatGPT"
 msgid "Assistant"
 msgstr "Ассистент"
 
-#: src/views/preference/AuthSettings.vue:17
+#: src/views/preference/AuthSettings.vue:18
 msgid "Attempts"
 msgstr ""
 
@@ -199,15 +203,15 @@ msgstr "Вернутся"
 msgid "Back to list"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:68
+#: src/views/preference/AuthSettings.vue:69
 msgid "Ban Threshold Minutes"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:82
+#: src/views/preference/AuthSettings.vue:83
 msgid "Banned IPs"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:20
+#: src/views/preference/AuthSettings.vue:21
 msgid "Banned Until"
 msgstr ""
 
@@ -364,6 +368,10 @@ msgstr "Настроить SSL"
 msgid "Connected"
 msgstr "Подключено"
 
+#: src/views/pty/Terminal.vue:120
+msgid "Connection lost, please refresh the page."
+msgstr ""
+
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:111
 #: src/views/domain/ngx_conf/LocationEditor.vue:125
 #: src/views/domain/ngx_conf/LocationEditor.vue:97
@@ -407,6 +415,14 @@ msgstr "Учетные данные"
 msgid "Credentials"
 msgstr "Учетные данные"
 
+#: src/views/preference/components/TOTP.vue:96
+msgid "Current account is enabled 2FA."
+msgstr ""
+
+#: src/views/preference/components/TOTP.vue:93
+msgid "Current account is not enabled 2FA."
+msgstr ""
+
 #: src/views/system/Upgrade.vue:167
 msgid "Current Version"
 msgstr "Текущяя версия"
@@ -696,6 +712,16 @@ msgstr "Включение %{conf_name} in %{node_name} нипалучилася
 msgid "Enable %{conf_name} in %{node_name} successfully"
 msgstr "Включение %{conf_name} in %{node_name} успешно"
 
+#: src/views/preference/components/TOTP.vue:122
+#, fuzzy
+msgid "Enable 2FA"
+msgstr "Включить"
+
+#: src/views/preference/components/TOTP.vue:52
+#, fuzzy
+msgid "Enable 2FA successfully"
+msgstr "Активировано успешно"
+
 #: src/views/domain/cert/components/ObtainCert.vue:70
 msgid "Enable auto-renewal failed for %{name}"
 msgstr "Не удалось включить автоматическое продление для %{name}"
@@ -898,12 +924,18 @@ msgstr ""
 msgid "If left blank, the default CA Dir will be used."
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:60
+#: src/views/preference/AuthSettings.vue:61
 msgid ""
 "If the number of login failed attempts from a ip reach the max attempts in "
 "ban threshold minutes, the ip will be banned for a period of time."
 msgstr ""
 
+#: src/views/preference/components/TOTP.vue:108
+msgid ""
+"If you lose your mobile phone, you can use the recovery code to reset your "
+"2FA."
+msgstr ""
+
 #: src/views/certificate/Certificate.vue:136
 #, fuzzy
 msgid "Import"
@@ -914,7 +946,7 @@ msgstr "Экспорт"
 msgid "Import Certificate"
 msgstr "Статус сертификата"
 
-#: src/views/other/Login.vue:59
+#: src/views/other/Login.vue:71
 #, fuzzy
 msgid "Incorrect username or password"
 msgstr "Имя пользователя или пароль неверны"
@@ -931,7 +963,16 @@ msgstr "Ошибка первоначального обновления ядр
 msgid "Initialing core upgrader"
 msgstr "Инициализация программы обновления ядра"
 
-#: src/routes/index.ts:273 src/views/other/Install.vue:135
+#: src/views/preference/components/TOTP.vue:144
+msgid "Input the code from the app:"
+msgstr ""
+
+#: src/components/OTP/OTPAuthorization.vue:49
+#: src/views/preference/components/TOTP.vue:157
+msgid "Input the recovery code:"
+msgstr ""
+
+#: src/routes/index.ts:283 src/views/other/Install.vue:135
 msgid "Install"
 msgstr "Установить"
 
@@ -953,7 +994,15 @@ msgstr ""
 msgid "Invalid"
 msgstr "Действительный"
 
-#: src/views/preference/AuthSettings.vue:14
+#: src/views/other/Login.vue:81
+msgid "Invalid 2FA or recovery code"
+msgstr ""
+
+#: src/components/OTP/useOTPModal.ts:43
+msgid "Invalid passcode or recovery code"
+msgstr ""
+
+#: src/views/preference/AuthSettings.vue:15
 msgid "IP"
 msgstr ""
 
@@ -1048,11 +1097,11 @@ msgstr "Locations"
 msgid "Log"
 msgstr "Логин"
 
-#: src/routes/index.ts:279 src/views/other/Login.vue:159
+#: src/routes/index.ts:289 src/views/other/Login.vue:192
 msgid "Login"
 msgstr "Логин"
 
-#: src/views/other/Login.vue:109 src/views/other/Login.vue:51
+#: src/views/other/Login.vue:125 src/views/other/Login.vue:60
 msgid "Login successful"
 msgstr "Авторизация успешна"
 
@@ -1096,7 +1145,7 @@ msgstr "Сайты"
 msgid "Manage Streams"
 msgstr "Управление потоками"
 
-#: src/routes/index.ts:230 src/views/user/User.vue:50
+#: src/routes/index.ts:235 src/views/user/User.vue:50
 msgid "Manage Users"
 msgstr "Пользователи"
 
@@ -1105,7 +1154,7 @@ msgstr "Пользователи"
 msgid "Managed Certificate"
 msgstr "Управление сертификатами"
 
-#: src/views/preference/AuthSettings.vue:74
+#: src/views/preference/AuthSettings.vue:75
 msgid "Max Attempts"
 msgstr ""
 
@@ -1240,7 +1289,7 @@ msgstr "Nginx успешно перезапущен"
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:90
 #: src/views/domain/ngx_conf/LocationEditor.vue:71
 #: src/views/notification/Notification.vue:70
-#: src/views/preference/AuthSettings.vue:96
+#: src/views/preference/AuthSettings.vue:97
 #: src/views/preference/BasicSettings.vue:101
 #: src/views/stream/StreamList.vue:165
 msgid "No"
@@ -1254,7 +1303,7 @@ msgstr ""
 msgid "Not After"
 msgstr ""
 
-#: src/routes/index.ts:285
+#: src/routes/index.ts:295
 msgid "Not Found"
 msgstr "Не найден"
 
@@ -1272,7 +1321,7 @@ msgstr "Заметка"
 msgid "Notification"
 msgstr "Сертификат"
 
-#: src/components/Notification/Notification.vue:82 src/routes/index.ts:221
+#: src/components/Notification/Notification.vue:82 src/routes/index.ts:226
 #, fuzzy
 msgid "Notifications"
 msgstr "Уведомления"
@@ -1355,7 +1404,7 @@ msgstr ""
 msgid "Params"
 msgstr "Параметры"
 
-#: src/views/other/Login.vue:144 src/views/user/User.vue:18
+#: src/views/other/Login.vue:169 src/views/user/User.vue:18
 msgid "Password"
 msgstr "Пароль"
 
@@ -1381,6 +1430,10 @@ msgstr ""
 msgid "Performing core upgrade"
 msgstr ""
 
+#: src/components/OTP/OTPAuthorization.vue:37
+msgid "Please enter the 2FA code:"
+msgstr ""
+
 #: src/views/certificate/DNSCredential.vue:53
 msgid ""
 "Please fill in the API authentication credentials provided by your DNS "
@@ -1410,11 +1463,11 @@ msgstr ""
 msgid "Please input your E-mail!"
 msgstr "Введите ваш E-mail!"
 
-#: src/views/other/Install.vue:45 src/views/other/Login.vue:39
+#: src/views/other/Install.vue:45 src/views/other/Login.vue:43
 msgid "Please input your password!"
 msgstr "Введите ваш пароль!"
 
-#: src/views/other/Install.vue:39 src/views/other/Login.vue:33
+#: src/views/other/Install.vue:39 src/views/other/Login.vue:37
 msgid "Please input your username!"
 msgstr "Введите ваше имя пользователя!"
 
@@ -1434,7 +1487,7 @@ msgstr ""
 msgid "Pre-release"
 msgstr ""
 
-#: src/routes/index.ts:239 src/views/preference/Preference.vue:105
+#: src/routes/index.ts:244 src/views/preference/Preference.vue:105
 msgid "Preference"
 msgstr "Настройки"
 
@@ -1478,6 +1531,19 @@ msgstr ""
 msgid "Recovered Successfully"
 msgstr "Успешно сохранено"
 
+#: src/components/OTP/OTPAuthorization.vue:56
+#: src/views/preference/components/TOTP.vue:167
+msgid "Recovery"
+msgstr ""
+
+#: src/views/preference/components/TOTP.vue:101
+msgid "Recovery Code"
+msgstr ""
+
+#: src/views/preference/components/TOTP.vue:110
+msgid "Recovery Code:"
+msgstr ""
+
 #: src/views/preference/BasicSettings.vue:68
 msgid "Recursive Nameservers"
 msgstr ""
@@ -1532,11 +1598,11 @@ msgstr "Перезагружается"
 msgid "Reloading nginx"
 msgstr "Перезагружается nginx"
 
-#: src/views/preference/AuthSettings.vue:101
+#: src/views/preference/AuthSettings.vue:102
 msgid "Remove"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:47
+#: src/views/preference/AuthSettings.vue:48
 #, fuzzy
 msgid "Remove successfully"
 msgstr "Успешно сохранено"
@@ -1581,6 +1647,11 @@ msgstr "Запрос с неправильными параметрами"
 msgid "Reset"
 msgstr "Сброс"
 
+#: src/views/preference/components/TOTP.vue:130
+#, fuzzy
+msgid "Reset 2FA"
+msgstr "Сброс"
+
 #: src/components/NginxControl/NginxControl.vue:93
 msgid "Restart"
 msgstr "Перезапуск"
@@ -1630,6 +1701,10 @@ msgstr "Успешно сохранено"
 msgid "Saved successfully"
 msgstr "Успешно сохранено"
 
+#: src/views/preference/components/TOTP.vue:91
+msgid "Scan the QR code with your mobile phone to add the account to the app."
+msgstr ""
+
 #: src/views/certificate/DNSChallenge.vue:89
 msgid "SDK"
 msgstr ""
@@ -1653,7 +1728,9 @@ msgstr "Отправлено"
 #: src/views/config/ConfigEdit.vue:40 src/views/domain/DomainList.vue:81
 #: src/views/environment/BatchUpgrader.vue:57
 #: src/views/environment/Environment.vue:15 src/views/other/Install.vue:69
-#: src/views/preference/AuthSettings.vue:49
+#: src/views/preference/AuthSettings.vue:50
+#: src/views/preference/components/TOTP.vue:42
+#: src/views/preference/components/TOTP.vue:55
 #: src/views/preference/Preference.vue:78 src/views/stream/StreamList.vue:113
 #: src/views/stream/StreamList.vue:81 src/views/system/Upgrade.vue:42
 msgid "Server error"
@@ -1730,7 +1807,7 @@ msgstr "Путь к ключу сертификата SSL"
 msgid "SSL Certificate Path"
 msgstr "Путь к сертификату SSL"
 
-#: src/views/other/Login.vue:170
+#: src/views/other/Login.vue:203
 #, fuzzy
 msgid "SSO Login"
 msgstr "Логин"
@@ -1815,7 +1892,7 @@ msgstr "Сертификат действителен"
 msgid "Sync to"
 msgstr ""
 
-#: src/routes/index.ts:248
+#: src/routes/index.ts:253
 msgid "System"
 msgstr "Система"
 
@@ -1824,7 +1901,7 @@ msgstr "Система"
 msgid "Target"
 msgstr ""
 
-#: src/routes/index.ts:172 src/views/pty/Terminal.vue:91
+#: src/routes/index.ts:172 src/views/pty/Terminal.vue:114
 msgid "Terminal"
 msgstr "Терминал"
 
@@ -1869,6 +1946,11 @@ msgstr "Путь к ключу сертификата SSL"
 msgid "The path exists, but the file is not a private key"
 msgstr "Путь существует, но файл не является приватным ключом"
 
+#: src/views/preference/components/TOTP.vue:109
+msgid ""
+"The recovery code is only displayed once, please save it in a safe place."
+msgstr ""
+
 #: src/views/dashboard/Environments.vue:148
 msgid ""
 "The remote Nginx UI version is not compatible with the local Nginx UI "
@@ -1931,7 +2013,7 @@ msgid ""
 "This will upgrade or reinstall the Nginx UI on %{nodeNames} to %{version}."
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:59
+#: src/views/preference/AuthSettings.vue:60
 #: src/views/preference/LogrotateSettings.vue:12
 msgid "Tips"
 msgstr ""
@@ -1940,6 +2022,12 @@ msgstr ""
 msgid "Title"
 msgstr "Заголовок"
 
+#: src/views/preference/components/TOTP.vue:90
+msgid ""
+"To enable it, you need to install the Google or Microsoft Authenticator app "
+"on your mobile phone."
+msgstr ""
+
 #: src/views/domain/ngx_conf/NgxConfigEditor.vue:44
 msgid ""
 "To make sure the certification auto-renewal can work normally, we need to "
@@ -1952,14 +2040,24 @@ msgstr ""
 msgid "Token is not valid"
 msgstr ""
 
-#: src/views/other/Login.vue:62
+#: src/views/other/Login.vue:74
 msgid "Too many login failed attempts, please try again later"
 msgstr ""
 
+#: src/views/preference/components/TOTP.vue:89
+msgid ""
+"TOTP is a two-factor authentication method that uses a time-based one-time "
+"password algorithm."
+msgstr ""
+
 #: src/components/StdDesign/StdDataDisplay/StdCurd.vue:209
 msgid "Trash"
 msgstr ""
 
+#: src/components/OTP/useOTPModal.ts:49
+msgid "Two-factor authentication required"
+msgstr ""
+
 #: src/views/certificate/Certificate.vue:37 src/views/config/config.ts:12
 #: src/views/notification/Notification.vue:13
 msgid "Type"
@@ -1980,7 +2078,7 @@ msgstr "Обновлено в"
 msgid "Updated successfully"
 msgstr "Обновлено успешно"
 
-#: src/routes/index.ts:263 src/views/environment/Environment.vue:50
+#: src/routes/index.ts:268 src/views/environment/Environment.vue:50
 #: src/views/system/Upgrade.vue:145 src/views/system/Upgrade.vue:228
 msgid "Upgrade"
 msgstr "Обновление"
@@ -2011,16 +2109,24 @@ msgstr "Аптайм:"
 msgid "URL"
 msgstr ""
 
+#: src/components/OTP/OTPAuthorization.vue:69
+msgid "Use OTP"
+msgstr ""
+
+#: src/components/OTP/OTPAuthorization.vue:65
+msgid "Use recovery code"
+msgstr ""
+
 #: src/components/ChatGPT/ChatGPT.vue:229
 #, fuzzy
 msgid "User"
 msgstr "Пользователь"
 
-#: src/views/other/Login.vue:65
+#: src/views/other/Login.vue:77
 msgid "User is banned"
 msgstr ""
 
-#: src/views/other/Login.vue:134 src/views/user/User.vue:9
+#: src/views/other/Login.vue:159 src/views/user/User.vue:9
 msgid "Username"
 msgstr "Имя пользователя"
 
@@ -2092,7 +2198,7 @@ msgstr "Запись сертификата на диск"
 
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:89
 #: src/views/domain/ngx_conf/LocationEditor.vue:70
-#: src/views/preference/AuthSettings.vue:95
+#: src/views/preference/AuthSettings.vue:96
 #: src/views/preference/BasicSettings.vue:100
 msgid "Yes"
 msgstr "Да"

+ 140 - 34
app/src/language/vi_VN/app.po

@@ -9,7 +9,11 @@ msgstr ""
 "Content-Transfer-Encoding: 8bit\n"
 "Plural-Forms: nplurals=2; plural=(n != 1);\n"
 
-#: src/routes/index.ts:256
+#: src/views/preference/components/TOTP.vue:88
+msgid "2FA Settings"
+msgstr ""
+
+#: src/routes/index.ts:261
 msgid "About"
 msgstr "Tác giả"
 
@@ -28,7 +32,7 @@ msgstr "Người dùng"
 #: src/views/certificate/DNSCredential.vue:33 src/views/config/config.ts:34
 #: src/views/domain/DomainList.vue:47 src/views/environment/envColumns.tsx:131
 #: src/views/notification/Notification.vue:37
-#: src/views/preference/AuthSettings.vue:26 src/views/stream/StreamList.vue:47
+#: src/views/preference/AuthSettings.vue:27 src/views/stream/StreamList.vue:47
 #: src/views/user/User.vue:43
 msgid "Action"
 msgstr "Hành động"
@@ -95,7 +99,7 @@ msgstr ""
 msgid "Arch"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:94
+#: src/views/preference/AuthSettings.vue:95
 #, fuzzy
 msgid "Are you sure to delete this banned IP immediately?"
 msgstr "Bạn chắc chắn muốn xóa nó "
@@ -153,7 +157,7 @@ msgstr "Hỏi ChatGPT"
 msgid "Assistant"
 msgstr "Trợ lý"
 
-#: src/views/preference/AuthSettings.vue:17
+#: src/views/preference/AuthSettings.vue:18
 msgid "Attempts"
 msgstr ""
 
@@ -199,15 +203,15 @@ msgstr "Quay lại"
 msgid "Back to list"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:68
+#: src/views/preference/AuthSettings.vue:69
 msgid "Ban Threshold Minutes"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:82
+#: src/views/preference/AuthSettings.vue:83
 msgid "Banned IPs"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:20
+#: src/views/preference/AuthSettings.vue:21
 msgid "Banned Until"
 msgstr ""
 
@@ -364,6 +368,10 @@ msgstr "Cấu hình SSL"
 msgid "Connected"
 msgstr "Đã kết nối"
 
+#: src/views/pty/Terminal.vue:120
+msgid "Connection lost, please refresh the page."
+msgstr ""
+
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:111
 #: src/views/domain/ngx_conf/LocationEditor.vue:125
 #: src/views/domain/ngx_conf/LocationEditor.vue:97
@@ -407,6 +415,14 @@ msgstr "Chứng chỉ"
 msgid "Credentials"
 msgstr "Chứng chỉ"
 
+#: src/views/preference/components/TOTP.vue:96
+msgid "Current account is enabled 2FA."
+msgstr ""
+
+#: src/views/preference/components/TOTP.vue:93
+msgid "Current account is not enabled 2FA."
+msgstr ""
+
 #: src/views/system/Upgrade.vue:167
 msgid "Current Version"
 msgstr "Phiên bản hiện tại"
@@ -697,6 +713,16 @@ msgstr "Không thể bật %{conf_name} trên %{node_name}"
 msgid "Enable %{conf_name} in %{node_name} successfully"
 msgstr "Đã bật %{conf_name} trên %{node_name}"
 
+#: src/views/preference/components/TOTP.vue:122
+#, fuzzy
+msgid "Enable 2FA"
+msgstr "Đã bật"
+
+#: src/views/preference/components/TOTP.vue:52
+#, fuzzy
+msgid "Enable 2FA successfully"
+msgstr "Đã bật"
+
 #: src/views/domain/cert/components/ObtainCert.vue:70
 msgid "Enable auto-renewal failed for %{name}"
 msgstr "Không thể bật tự động gia hạn SSL cho %{name}"
@@ -900,12 +926,18 @@ msgstr ""
 msgid "If left blank, the default CA Dir will be used."
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:60
+#: src/views/preference/AuthSettings.vue:61
 msgid ""
 "If the number of login failed attempts from a ip reach the max attempts in "
 "ban threshold minutes, the ip will be banned for a period of time."
 msgstr ""
 
+#: src/views/preference/components/TOTP.vue:108
+msgid ""
+"If you lose your mobile phone, you can use the recovery code to reset your "
+"2FA."
+msgstr ""
+
 #: src/views/certificate/Certificate.vue:136
 #, fuzzy
 msgid "Import"
@@ -916,7 +948,7 @@ msgstr "Xuất"
 msgid "Import Certificate"
 msgstr "Chứng chỉ"
 
-#: src/views/other/Login.vue:59
+#: src/views/other/Login.vue:71
 #, fuzzy
 msgid "Incorrect username or password"
 msgstr "Tên người dùng hoặc mật khẩu không chính xác"
@@ -933,7 +965,16 @@ msgstr "Không thể khởi tạo trình nâng cấp"
 msgid "Initialing core upgrader"
 msgstr "Đang khởi tạo trình nâng cấp"
 
-#: src/routes/index.ts:273 src/views/other/Install.vue:135
+#: src/views/preference/components/TOTP.vue:144
+msgid "Input the code from the app:"
+msgstr ""
+
+#: src/components/OTP/OTPAuthorization.vue:49
+#: src/views/preference/components/TOTP.vue:157
+msgid "Input the recovery code:"
+msgstr ""
+
+#: src/routes/index.ts:283 src/views/other/Install.vue:135
 msgid "Install"
 msgstr "Cài đặt"
 
@@ -955,7 +996,15 @@ msgstr ""
 msgid "Invalid"
 msgstr "Hợp lệ"
 
-#: src/views/preference/AuthSettings.vue:14
+#: src/views/other/Login.vue:81
+msgid "Invalid 2FA or recovery code"
+msgstr ""
+
+#: src/components/OTP/useOTPModal.ts:43
+msgid "Invalid passcode or recovery code"
+msgstr ""
+
+#: src/views/preference/AuthSettings.vue:15
 msgid "IP"
 msgstr ""
 
@@ -1050,11 +1099,11 @@ msgstr "Locations"
 msgid "Log"
 msgstr "Log"
 
-#: src/routes/index.ts:279 src/views/other/Login.vue:159
+#: src/routes/index.ts:289 src/views/other/Login.vue:192
 msgid "Login"
 msgstr "Đăng nhập"
 
-#: src/views/other/Login.vue:109 src/views/other/Login.vue:51
+#: src/views/other/Login.vue:125 src/views/other/Login.vue:60
 msgid "Login successful"
 msgstr "Đăng nhập thành công"
 
@@ -1098,7 +1147,7 @@ msgstr "Quản lý Website"
 msgid "Manage Streams"
 msgstr "Quản lý Website"
 
-#: src/routes/index.ts:230 src/views/user/User.vue:50
+#: src/routes/index.ts:235 src/views/user/User.vue:50
 msgid "Manage Users"
 msgstr "Người dùng"
 
@@ -1106,7 +1155,7 @@ msgstr "Người dùng"
 msgid "Managed Certificate"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:74
+#: src/views/preference/AuthSettings.vue:75
 msgid "Max Attempts"
 msgstr ""
 
@@ -1240,7 +1289,7 @@ msgstr "Restart Nginx thành công"
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:90
 #: src/views/domain/ngx_conf/LocationEditor.vue:71
 #: src/views/notification/Notification.vue:70
-#: src/views/preference/AuthSettings.vue:96
+#: src/views/preference/AuthSettings.vue:97
 #: src/views/preference/BasicSettings.vue:101
 #: src/views/stream/StreamList.vue:165
 msgid "No"
@@ -1254,7 +1303,7 @@ msgstr ""
 msgid "Not After"
 msgstr "Không phải sau khi"
 
-#: src/routes/index.ts:285
+#: src/routes/index.ts:295
 msgid "Not Found"
 msgstr "Không tìm thấy"
 
@@ -1272,7 +1321,7 @@ msgstr "Ghi chú"
 msgid "Notification"
 msgstr "Thông báo"
 
-#: src/components/Notification/Notification.vue:82 src/routes/index.ts:221
+#: src/components/Notification/Notification.vue:82 src/routes/index.ts:226
 #, fuzzy
 msgid "Notifications"
 msgstr "Thông báo"
@@ -1355,7 +1404,7 @@ msgstr "Ghi đè tập tin đã tồn tại"
 msgid "Params"
 msgstr "Tham số"
 
-#: src/views/other/Login.vue:144 src/views/user/User.vue:18
+#: src/views/other/Login.vue:169 src/views/user/User.vue:18
 msgid "Password"
 msgstr "Mật khẩu"
 
@@ -1381,6 +1430,10 @@ msgstr "Nâng cấp core không thành công"
 msgid "Performing core upgrade"
 msgstr "Nâng cấp core"
 
+#: src/components/OTP/OTPAuthorization.vue:37
+msgid "Please enter the 2FA code:"
+msgstr ""
+
 #: src/views/certificate/DNSCredential.vue:53
 msgid ""
 "Please fill in the API authentication credentials provided by your DNS "
@@ -1412,11 +1465,11 @@ msgstr ""
 msgid "Please input your E-mail!"
 msgstr "Vui lòng nhập E-mail của bạn!"
 
-#: src/views/other/Install.vue:45 src/views/other/Login.vue:39
+#: src/views/other/Install.vue:45 src/views/other/Login.vue:43
 msgid "Please input your password!"
 msgstr "Vui lòng nhập mật khẩu!"
 
-#: src/views/other/Install.vue:39 src/views/other/Login.vue:33
+#: src/views/other/Install.vue:39 src/views/other/Login.vue:37
 msgid "Please input your username!"
 msgstr "Vui lòng nhập username!"
 
@@ -1436,7 +1489,7 @@ msgstr ""
 msgid "Pre-release"
 msgstr ""
 
-#: src/routes/index.ts:239 src/views/preference/Preference.vue:105
+#: src/routes/index.ts:244 src/views/preference/Preference.vue:105
 msgid "Preference"
 msgstr "Cài đặt"
 
@@ -1480,6 +1533,19 @@ msgstr ""
 msgid "Recovered Successfully"
 msgstr "Xoá thành công"
 
+#: src/components/OTP/OTPAuthorization.vue:56
+#: src/views/preference/components/TOTP.vue:167
+msgid "Recovery"
+msgstr ""
+
+#: src/views/preference/components/TOTP.vue:101
+msgid "Recovery Code"
+msgstr ""
+
+#: src/views/preference/components/TOTP.vue:110
+msgid "Recovery Code:"
+msgstr ""
+
 #: src/views/preference/BasicSettings.vue:68
 msgid "Recursive Nameservers"
 msgstr ""
@@ -1534,11 +1600,11 @@ msgstr "Đang tải lại"
 msgid "Reloading nginx"
 msgstr "Tải lại nginx"
 
-#: src/views/preference/AuthSettings.vue:101
+#: src/views/preference/AuthSettings.vue:102
 msgid "Remove"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:47
+#: src/views/preference/AuthSettings.vue:48
 #, fuzzy
 msgid "Remove successfully"
 msgstr "Xoá thành công"
@@ -1583,6 +1649,11 @@ msgstr "Yêu cầu có chứa tham số sai"
 msgid "Reset"
 msgstr "Đặt lại"
 
+#: src/views/preference/components/TOTP.vue:130
+#, fuzzy
+msgid "Reset 2FA"
+msgstr "Đặt lại"
+
 #: src/components/NginxControl/NginxControl.vue:93
 msgid "Restart"
 msgstr "Khởi động lại"
@@ -1632,6 +1703,10 @@ msgstr "Lưu thành công"
 msgid "Saved successfully"
 msgstr "Lưu thành công"
 
+#: src/views/preference/components/TOTP.vue:91
+msgid "Scan the QR code with your mobile phone to add the account to the app."
+msgstr ""
+
 #: src/views/certificate/DNSChallenge.vue:89
 msgid "SDK"
 msgstr ""
@@ -1655,7 +1730,9 @@ msgstr "Gửi"
 #: src/views/config/ConfigEdit.vue:40 src/views/domain/DomainList.vue:81
 #: src/views/environment/BatchUpgrader.vue:57
 #: src/views/environment/Environment.vue:15 src/views/other/Install.vue:69
-#: src/views/preference/AuthSettings.vue:49
+#: src/views/preference/AuthSettings.vue:50
+#: src/views/preference/components/TOTP.vue:42
+#: src/views/preference/components/TOTP.vue:55
 #: src/views/preference/Preference.vue:78 src/views/stream/StreamList.vue:113
 #: src/views/stream/StreamList.vue:81 src/views/system/Upgrade.vue:42
 msgid "Server error"
@@ -1729,7 +1806,7 @@ msgstr ""
 msgid "SSL Certificate Path"
 msgstr ""
 
-#: src/views/other/Login.vue:170
+#: src/views/other/Login.vue:203
 msgid "SSO Login"
 msgstr ""
 
@@ -1813,7 +1890,7 @@ msgstr "Gia hạn chứng chỉ SSL thành công"
 msgid "Sync to"
 msgstr ""
 
-#: src/routes/index.ts:248
+#: src/routes/index.ts:253
 msgid "System"
 msgstr "Thông tin"
 
@@ -1822,7 +1899,7 @@ msgstr "Thông tin"
 msgid "Target"
 msgstr "Mục tiêu"
 
-#: src/routes/index.ts:172 src/views/pty/Terminal.vue:91
+#: src/routes/index.ts:172 src/views/pty/Terminal.vue:114
 msgid "Terminal"
 msgstr "Terminal"
 
@@ -1865,6 +1942,11 @@ msgstr ""
 msgid "The path exists, but the file is not a private key"
 msgstr ""
 
+#: src/views/preference/components/TOTP.vue:109
+msgid ""
+"The recovery code is only displayed once, please save it in a safe place."
+msgstr ""
+
 #: src/views/dashboard/Environments.vue:148
 msgid ""
 "The remote Nginx UI version is not compatible with the local Nginx UI "
@@ -1923,7 +2005,7 @@ msgid ""
 "This will upgrade or reinstall the Nginx UI on %{nodeNames} to %{version}."
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:59
+#: src/views/preference/AuthSettings.vue:60
 #: src/views/preference/LogrotateSettings.vue:12
 msgid "Tips"
 msgstr ""
@@ -1932,6 +2014,12 @@ msgstr ""
 msgid "Title"
 msgstr "Tiêu đề"
 
+#: src/views/preference/components/TOTP.vue:90
+msgid ""
+"To enable it, you need to install the Google or Microsoft Authenticator app "
+"on your mobile phone."
+msgstr ""
+
 #: src/views/domain/ngx_conf/NgxConfigEditor.vue:44
 msgid ""
 "To make sure the certification auto-renewal can work normally, we need to "
@@ -1948,14 +2036,24 @@ msgstr ""
 msgid "Token is not valid"
 msgstr ""
 
-#: src/views/other/Login.vue:62
+#: src/views/other/Login.vue:74
 msgid "Too many login failed attempts, please try again later"
 msgstr ""
 
+#: src/views/preference/components/TOTP.vue:89
+msgid ""
+"TOTP is a two-factor authentication method that uses a time-based one-time "
+"password algorithm."
+msgstr ""
+
 #: src/components/StdDesign/StdDataDisplay/StdCurd.vue:209
 msgid "Trash"
 msgstr ""
 
+#: src/components/OTP/useOTPModal.ts:49
+msgid "Two-factor authentication required"
+msgstr ""
+
 #: src/views/certificate/Certificate.vue:37 src/views/config/config.ts:12
 #: src/views/notification/Notification.vue:13
 msgid "Type"
@@ -1976,7 +2074,7 @@ msgstr "Ngày cập nhật"
 msgid "Updated successfully"
 msgstr "Cập nhật thành công"
 
-#: src/routes/index.ts:263 src/views/environment/Environment.vue:50
+#: src/routes/index.ts:268 src/views/environment/Environment.vue:50
 #: src/views/system/Upgrade.vue:145 src/views/system/Upgrade.vue:228
 msgid "Upgrade"
 msgstr "Cập nhật"
@@ -2007,16 +2105,24 @@ msgstr "Thời gian hoạt động:"
 msgid "URL"
 msgstr ""
 
+#: src/components/OTP/OTPAuthorization.vue:69
+msgid "Use OTP"
+msgstr ""
+
+#: src/components/OTP/OTPAuthorization.vue:65
+msgid "Use recovery code"
+msgstr ""
+
 #: src/components/ChatGPT/ChatGPT.vue:229
 #, fuzzy
 msgid "User"
 msgstr "Người dùng"
 
-#: src/views/other/Login.vue:65
+#: src/views/other/Login.vue:77
 msgid "User is banned"
 msgstr ""
 
-#: src/views/other/Login.vue:134 src/views/user/User.vue:9
+#: src/views/other/Login.vue:159 src/views/user/User.vue:9
 msgid "Username"
 msgstr "Username"
 
@@ -2091,7 +2197,7 @@ msgstr "Ghi chứng chỉ vào disk"
 
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:89
 #: src/views/domain/ngx_conf/LocationEditor.vue:70
-#: src/views/preference/AuthSettings.vue:95
+#: src/views/preference/AuthSettings.vue:96
 #: src/views/preference/BasicSettings.vue:100
 msgid "Yes"
 msgstr "Có"

BIN
app/src/language/zh_CN/app.mo


+ 138 - 34
app/src/language/zh_CN/app.po

@@ -13,7 +13,11 @@ msgstr ""
 "Generated-By: easygettext\n"
 "X-Generator: Poedit 3.4.4\n"
 
-#: src/routes/index.ts:256
+#: src/views/preference/components/TOTP.vue:88
+msgid "2FA Settings"
+msgstr "2FA 设置"
+
+#: src/routes/index.ts:261
 msgid "About"
 msgstr "关于"
 
@@ -31,7 +35,7 @@ msgstr "ACME 用户"
 #: src/views/certificate/DNSCredential.vue:33 src/views/config/config.ts:34
 #: src/views/domain/DomainList.vue:47 src/views/environment/envColumns.tsx:131
 #: src/views/notification/Notification.vue:37
-#: src/views/preference/AuthSettings.vue:26 src/views/stream/StreamList.vue:47
+#: src/views/preference/AuthSettings.vue:27 src/views/stream/StreamList.vue:47
 #: src/views/user/User.vue:43
 msgid "Action"
 msgstr "操作"
@@ -95,7 +99,7 @@ msgstr "API Token"
 msgid "Arch"
 msgstr "架构"
 
-#: src/views/preference/AuthSettings.vue:94
+#: src/views/preference/AuthSettings.vue:95
 msgid "Are you sure to delete this banned IP immediately?"
 msgstr "您确定要立即删除这个被禁用的 IP 吗?"
 
@@ -144,7 +148,7 @@ msgstr "与ChatGPT聊天"
 msgid "Assistant"
 msgstr "助手"
 
-#: src/views/preference/AuthSettings.vue:17
+#: src/views/preference/AuthSettings.vue:18
 msgid "Attempts"
 msgstr "尝试次数"
 
@@ -188,15 +192,15 @@ msgstr "返回首页"
 msgid "Back to list"
 msgstr "返回列表"
 
-#: src/views/preference/AuthSettings.vue:68
+#: src/views/preference/AuthSettings.vue:69
 msgid "Ban Threshold Minutes"
 msgstr "禁止阈值(分钟)"
 
-#: src/views/preference/AuthSettings.vue:82
+#: src/views/preference/AuthSettings.vue:83
 msgid "Banned IPs"
 msgstr "禁止 IP 列表"
 
-#: src/views/preference/AuthSettings.vue:20
+#: src/views/preference/AuthSettings.vue:21
 msgid "Banned Until"
 msgstr "禁用至"
 
@@ -343,6 +347,10 @@ msgstr "配置 SSL"
 msgid "Connected"
 msgstr "已连接"
 
+#: src/views/pty/Terminal.vue:120
+msgid "Connection lost, please refresh the page."
+msgstr "连接中断,请刷新页面。"
+
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:111
 #: src/views/domain/ngx_conf/LocationEditor.vue:125
 #: src/views/domain/ngx_conf/LocationEditor.vue:97
@@ -385,6 +393,14 @@ msgstr "DNS 凭证"
 msgid "Credentials"
 msgstr "凭证"
 
+#: src/views/preference/components/TOTP.vue:96
+msgid "Current account is enabled 2FA."
+msgstr "当前账户已启用二步验证。"
+
+#: src/views/preference/components/TOTP.vue:93
+msgid "Current account is not enabled 2FA."
+msgstr "当前用户未启用二步验证。"
+
 #: src/views/system/Upgrade.vue:167
 msgid "Current Version"
 msgstr "当前版本"
@@ -652,6 +668,14 @@ msgstr "在%{node_name}中启用%{conf_name}失败"
 msgid "Enable %{conf_name} in %{node_name} successfully"
 msgstr "成功启用%{node_name}中的%{conf_name}"
 
+#: src/views/preference/components/TOTP.vue:122
+msgid "Enable 2FA"
+msgstr "启用二步验证"
+
+#: src/views/preference/components/TOTP.vue:52
+msgid "Enable 2FA successfully"
+msgstr "二步验证启用成功"
+
 #: src/views/domain/cert/components/ObtainCert.vue:70
 msgid "Enable auto-renewal failed for %{name}"
 msgstr "启用 %{name} 自动续签失败"
@@ -844,7 +868,7 @@ msgstr "HTTP01"
 msgid "If left blank, the default CA Dir will be used."
 msgstr "如果留空,则使用默认 CA Dir。"
 
-#: src/views/preference/AuthSettings.vue:60
+#: src/views/preference/AuthSettings.vue:61
 msgid ""
 "If the number of login failed attempts from a ip reach the max attempts in "
 "ban threshold minutes, the ip will be banned for a period of time."
@@ -852,6 +876,12 @@ msgstr ""
 "如果某个 IP 的登录失败次数达到禁用阈值分钟内的最大尝试次数,该 IP 将被禁止登"
 "录一段时间。"
 
+#: src/views/preference/components/TOTP.vue:108
+msgid ""
+"If you lose your mobile phone, you can use the recovery code to reset your "
+"2FA."
+msgstr "如果丢失了手机,可以使用恢复代码重置二步验证。"
+
 #: src/views/certificate/Certificate.vue:136
 msgid "Import"
 msgstr "导入"
@@ -860,7 +890,7 @@ msgstr "导入"
 msgid "Import Certificate"
 msgstr "导入证书"
 
-#: src/views/other/Login.vue:59
+#: src/views/other/Login.vue:71
 msgid "Incorrect username or password"
 msgstr "用户名或密码错误"
 
@@ -876,7 +906,16 @@ msgstr "初始化核心升级程序错误"
 msgid "Initialing core upgrader"
 msgstr "初始化核心升级器"
 
-#: src/routes/index.ts:273 src/views/other/Install.vue:135
+#: src/views/preference/components/TOTP.vue:144
+msgid "Input the code from the app:"
+msgstr "输入应用程序中的代码:"
+
+#: src/components/OTP/OTPAuthorization.vue:49
+#: src/views/preference/components/TOTP.vue:157
+msgid "Input the recovery code:"
+msgstr "输入恢复代码:"
+
+#: src/routes/index.ts:283 src/views/other/Install.vue:135
 msgid "Install"
 msgstr "安装"
 
@@ -896,7 +935,15 @@ msgstr "间隔"
 msgid "Invalid"
 msgstr "无效的"
 
-#: src/views/preference/AuthSettings.vue:14
+#: src/views/other/Login.vue:81
+msgid "Invalid 2FA or recovery code"
+msgstr "无效的二步验证码或恢复密码"
+
+#: src/components/OTP/useOTPModal.ts:43
+msgid "Invalid passcode or recovery code"
+msgstr "二次验证码或恢复代码无效"
+
+#: src/views/preference/AuthSettings.vue:15
 msgid "IP"
 msgstr "IP"
 
@@ -981,11 +1028,11 @@ msgstr "Locations"
 msgid "Log"
 msgstr "日志"
 
-#: src/routes/index.ts:279 src/views/other/Login.vue:159
+#: src/routes/index.ts:289 src/views/other/Login.vue:192
 msgid "Login"
 msgstr "登录"
 
-#: src/views/other/Login.vue:109 src/views/other/Login.vue:51
+#: src/views/other/Login.vue:125 src/views/other/Login.vue:60
 msgid "Login successful"
 msgstr "登录成功"
 
@@ -1031,7 +1078,7 @@ msgstr "网站管理"
 msgid "Manage Streams"
 msgstr "管理 Stream"
 
-#: src/routes/index.ts:230 src/views/user/User.vue:50
+#: src/routes/index.ts:235 src/views/user/User.vue:50
 msgid "Manage Users"
 msgstr "用户管理"
 
@@ -1039,7 +1086,7 @@ msgstr "用户管理"
 msgid "Managed Certificate"
 msgstr "托管证书"
 
-#: src/views/preference/AuthSettings.vue:74
+#: src/views/preference/AuthSettings.vue:75
 msgid "Max Attempts"
 msgstr "最大尝试次数"
 
@@ -1165,7 +1212,7 @@ msgstr "Nginx 重启成功"
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:90
 #: src/views/domain/ngx_conf/LocationEditor.vue:71
 #: src/views/notification/Notification.vue:70
-#: src/views/preference/AuthSettings.vue:96
+#: src/views/preference/AuthSettings.vue:97
 #: src/views/preference/BasicSettings.vue:101
 #: src/views/stream/StreamList.vue:165
 msgid "No"
@@ -1179,7 +1226,7 @@ msgstr "节点密钥"
 msgid "Not After"
 msgstr "有效期"
 
-#: src/routes/index.ts:285
+#: src/routes/index.ts:295
 msgid "Not Found"
 msgstr "找不到页面"
 
@@ -1196,7 +1243,7 @@ msgstr "注意"
 msgid "Notification"
 msgstr "通知"
 
-#: src/components/Notification/Notification.vue:82 src/routes/index.ts:221
+#: src/components/Notification/Notification.vue:82 src/routes/index.ts:226
 msgid "Notifications"
 msgstr "通知"
 
@@ -1276,7 +1323,7 @@ msgstr "覆盖现有文件"
 msgid "Params"
 msgstr "参数"
 
-#: src/views/other/Login.vue:144 src/views/user/User.vue:18
+#: src/views/other/Login.vue:169 src/views/user/User.vue:18
 msgid "Password"
 msgstr "密码"
 
@@ -1302,6 +1349,10 @@ msgstr "执行核心升级错误"
 msgid "Performing core upgrade"
 msgstr "正在进行核心升级"
 
+#: src/components/OTP/OTPAuthorization.vue:37
+msgid "Please enter the 2FA code:"
+msgstr "请输入二步验证码:"
+
 #: src/views/certificate/DNSCredential.vue:53
 msgid ""
 "Please fill in the API authentication credentials provided by your DNS "
@@ -1331,11 +1382,11 @@ msgstr "请输入名称,这将被用作新配置的文件名!"
 msgid "Please input your E-mail!"
 msgstr "请输入您的邮箱!"
 
-#: src/views/other/Install.vue:45 src/views/other/Login.vue:39
+#: src/views/other/Install.vue:45 src/views/other/Login.vue:43
 msgid "Please input your password!"
 msgstr "请输入您的密码!"
 
-#: src/views/other/Install.vue:39 src/views/other/Login.vue:33
+#: src/views/other/Install.vue:39 src/views/other/Login.vue:37
 msgid "Please input your username!"
 msgstr "请输入您的用户名!"
 
@@ -1355,7 +1406,7 @@ msgstr "请至少选择一个节点!"
 msgid "Pre-release"
 msgstr "预发布"
 
-#: src/routes/index.ts:239 src/views/preference/Preference.vue:105
+#: src/routes/index.ts:244 src/views/preference/Preference.vue:105
 msgid "Preference"
 msgstr "偏好设置"
 
@@ -1397,6 +1448,19 @@ msgstr "恢复"
 msgid "Recovered Successfully"
 msgstr "恢复成功"
 
+#: src/components/OTP/OTPAuthorization.vue:56
+#: src/views/preference/components/TOTP.vue:167
+msgid "Recovery"
+msgstr "恢复"
+
+#: src/views/preference/components/TOTP.vue:101
+msgid "Recovery Code"
+msgstr "恢复代码"
+
+#: src/views/preference/components/TOTP.vue:110
+msgid "Recovery Code:"
+msgstr "恢复代码:"
+
 #: src/views/preference/BasicSettings.vue:68
 msgid "Recursive Nameservers"
 msgstr "递归域名服务器"
@@ -1446,11 +1510,11 @@ msgstr "重载中"
 msgid "Reloading nginx"
 msgstr "正在重载 Nginx"
 
-#: src/views/preference/AuthSettings.vue:101
+#: src/views/preference/AuthSettings.vue:102
 msgid "Remove"
 msgstr "删除"
 
-#: src/views/preference/AuthSettings.vue:47
+#: src/views/preference/AuthSettings.vue:48
 msgid "Remove successfully"
 msgstr "移除成功"
 
@@ -1488,6 +1552,10 @@ msgstr "请求参数错误"
 msgid "Reset"
 msgstr "重置"
 
+#: src/views/preference/components/TOTP.vue:130
+msgid "Reset 2FA"
+msgstr "重置二步验证"
+
 #: src/components/NginxControl/NginxControl.vue:93
 msgid "Restart"
 msgstr "重启"
@@ -1535,6 +1603,10 @@ msgstr "保存成功"
 msgid "Saved successfully"
 msgstr "保存成功"
 
+#: src/views/preference/components/TOTP.vue:91
+msgid "Scan the QR code with your mobile phone to add the account to the app."
+msgstr "用手机扫描二维码,将账户添加到应用程序中。"
+
 #: src/views/certificate/DNSChallenge.vue:89
 msgid "SDK"
 msgstr "SDK"
@@ -1558,7 +1630,9 @@ msgstr "上传"
 #: src/views/config/ConfigEdit.vue:40 src/views/domain/DomainList.vue:81
 #: src/views/environment/BatchUpgrader.vue:57
 #: src/views/environment/Environment.vue:15 src/views/other/Install.vue:69
-#: src/views/preference/AuthSettings.vue:49
+#: src/views/preference/AuthSettings.vue:50
+#: src/views/preference/components/TOTP.vue:42
+#: src/views/preference/components/TOTP.vue:55
 #: src/views/preference/Preference.vue:78 src/views/stream/StreamList.vue:113
 #: src/views/stream/StreamList.vue:81 src/views/system/Upgrade.vue:42
 msgid "Server error"
@@ -1629,7 +1703,7 @@ msgstr "SSL证书密钥路径"
 msgid "SSL Certificate Path"
 msgstr "SSL证书路径"
 
-#: src/views/other/Login.vue:170
+#: src/views/other/Login.vue:203
 msgid "SSO Login"
 msgstr "SSO 登录"
 
@@ -1707,7 +1781,7 @@ msgstr "同步证书成功"
 msgid "Sync to"
 msgstr "同步到"
 
-#: src/routes/index.ts:248
+#: src/routes/index.ts:253
 msgid "System"
 msgstr "系统"
 
@@ -1716,7 +1790,7 @@ msgstr "系统"
 msgid "Target"
 msgstr "目标"
 
-#: src/routes/index.ts:172 src/views/pty/Terminal.vue:91
+#: src/routes/index.ts:172 src/views/pty/Terminal.vue:114
 msgid "Terminal"
 msgstr "终端"
 
@@ -1758,6 +1832,11 @@ msgstr "路径存在,但文件不是证书"
 msgid "The path exists, but the file is not a private key"
 msgstr "路径存在,但文件不是私钥"
 
+#: src/views/preference/components/TOTP.vue:109
+msgid ""
+"The recovery code is only displayed once, please save it in a safe place."
+msgstr "恢复密码只会显示一次,请妥善保存。"
+
 #: src/views/dashboard/Environments.vue:148
 msgid ""
 "The remote Nginx UI version is not compatible with the local Nginx UI "
@@ -1816,7 +1895,7 @@ msgid ""
 "This will upgrade or reinstall the Nginx UI on %{nodeNames} to %{version}."
 msgstr "将 %{nodeNames} 上的 Nginx UI 升级或重新安装到 %{version} 版本。"
 
-#: src/views/preference/AuthSettings.vue:59
+#: src/views/preference/AuthSettings.vue:60
 #: src/views/preference/LogrotateSettings.vue:12
 msgid "Tips"
 msgstr "提示"
@@ -1825,6 +1904,13 @@ msgstr "提示"
 msgid "Title"
 msgstr "标题"
 
+#: src/views/preference/components/TOTP.vue:90
+msgid ""
+"To enable it, you need to install the Google or Microsoft Authenticator app "
+"on your mobile phone."
+msgstr ""
+"要启用该功能,您需要在手机上安装 Google 或 Microsoft Authenticator 应用程序。"
+
 #: src/views/domain/ngx_conf/NgxConfigEditor.vue:44
 msgid ""
 "To make sure the certification auto-renewal can work normally, we need to "
@@ -1839,14 +1925,24 @@ msgstr ""
 msgid "Token is not valid"
 msgstr "Token 无效"
 
-#: src/views/other/Login.vue:62
+#: src/views/other/Login.vue:74
 msgid "Too many login failed attempts, please try again later"
 msgstr "登录失败次数过多,请稍后再试"
 
+#: src/views/preference/components/TOTP.vue:89
+msgid ""
+"TOTP is a two-factor authentication method that uses a time-based one-time "
+"password algorithm."
+msgstr "TOTP 是一种使用基于时间的一次性密码算法的双因素身份验证方法。"
+
 #: src/components/StdDesign/StdDataDisplay/StdCurd.vue:209
 msgid "Trash"
 msgstr "回收站"
 
+#: src/components/OTP/useOTPModal.ts:49
+msgid "Two-factor authentication required"
+msgstr "需要两步验证"
+
 #: src/views/certificate/Certificate.vue:37 src/views/config/config.ts:12
 #: src/views/notification/Notification.vue:13
 msgid "Type"
@@ -1866,7 +1962,7 @@ msgstr "修改时间"
 msgid "Updated successfully"
 msgstr "更新成功"
 
-#: src/routes/index.ts:263 src/views/environment/Environment.vue:50
+#: src/routes/index.ts:268 src/views/environment/Environment.vue:50
 #: src/views/system/Upgrade.vue:145 src/views/system/Upgrade.vue:228
 msgid "Upgrade"
 msgstr "升级"
@@ -1895,15 +1991,23 @@ msgstr "运行时间:"
 msgid "URL"
 msgstr "URL"
 
+#: src/components/OTP/OTPAuthorization.vue:69
+msgid "Use OTP"
+msgstr "使用二步验证码"
+
+#: src/components/OTP/OTPAuthorization.vue:65
+msgid "Use recovery code"
+msgstr "使用恢复代码"
+
 #: src/components/ChatGPT/ChatGPT.vue:229
 msgid "User"
 msgstr "用户"
 
-#: src/views/other/Login.vue:65
+#: src/views/other/Login.vue:77
 msgid "User is banned"
 msgstr "用户被禁止"
 
-#: src/views/other/Login.vue:134 src/views/user/User.vue:9
+#: src/views/other/Login.vue:159 src/views/user/User.vue:9
 msgid "Username"
 msgstr "用户名"
 
@@ -1971,7 +2075,7 @@ msgstr "正在将证书写入磁盘"
 
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:89
 #: src/views/domain/ngx_conf/LocationEditor.vue:70
-#: src/views/preference/AuthSettings.vue:95
+#: src/views/preference/AuthSettings.vue:96
 #: src/views/preference/BasicSettings.vue:100
 msgid "Yes"
 msgstr "是的"

+ 140 - 34
app/src/language/zh_TW/app.po

@@ -14,7 +14,11 @@ msgstr ""
 "Generated-By: easygettext\n"
 "X-Generator: Poedit 3.4.1\n"
 
-#: src/routes/index.ts:256
+#: src/views/preference/components/TOTP.vue:88
+msgid "2FA Settings"
+msgstr ""
+
+#: src/routes/index.ts:261
 msgid "About"
 msgstr "關於"
 
@@ -33,7 +37,7 @@ msgstr "使用者名稱"
 #: src/views/certificate/DNSCredential.vue:33 src/views/config/config.ts:34
 #: src/views/domain/DomainList.vue:47 src/views/environment/envColumns.tsx:131
 #: src/views/notification/Notification.vue:37
-#: src/views/preference/AuthSettings.vue:26 src/views/stream/StreamList.vue:47
+#: src/views/preference/AuthSettings.vue:27 src/views/stream/StreamList.vue:47
 #: src/views/user/User.vue:43
 msgid "Action"
 msgstr "操作"
@@ -100,7 +104,7 @@ msgstr "API Token"
 msgid "Arch"
 msgstr "架構"
 
-#: src/views/preference/AuthSettings.vue:94
+#: src/views/preference/AuthSettings.vue:95
 #, fuzzy
 msgid "Are you sure to delete this banned IP immediately?"
 msgstr "您確定要刪除嗎?"
@@ -155,7 +159,7 @@ msgstr "向 ChatGPT 尋求幫助"
 msgid "Assistant"
 msgstr "助理"
 
-#: src/views/preference/AuthSettings.vue:17
+#: src/views/preference/AuthSettings.vue:18
 msgid "Attempts"
 msgstr ""
 
@@ -200,15 +204,15 @@ msgstr "返回首頁"
 msgid "Back to list"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:68
+#: src/views/preference/AuthSettings.vue:69
 msgid "Ban Threshold Minutes"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:82
+#: src/views/preference/AuthSettings.vue:83
 msgid "Banned IPs"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:20
+#: src/views/preference/AuthSettings.vue:21
 msgid "Banned Until"
 msgstr ""
 
@@ -361,6 +365,10 @@ msgstr "設定 SSL"
 msgid "Connected"
 msgstr "已連結"
 
+#: src/views/pty/Terminal.vue:120
+msgid "Connection lost, please refresh the page."
+msgstr ""
+
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:111
 #: src/views/domain/ngx_conf/LocationEditor.vue:125
 #: src/views/domain/ngx_conf/LocationEditor.vue:97
@@ -404,6 +412,14 @@ msgstr "認證"
 msgid "Credentials"
 msgstr "認證資訊"
 
+#: src/views/preference/components/TOTP.vue:96
+msgid "Current account is enabled 2FA."
+msgstr ""
+
+#: src/views/preference/components/TOTP.vue:93
+msgid "Current account is not enabled 2FA."
+msgstr ""
+
 #: src/views/system/Upgrade.vue:167
 msgid "Current Version"
 msgstr "目前版本"
@@ -680,6 +696,16 @@ msgstr "在 %{node_name} 啟用 %{conf_name} 失敗"
 msgid "Enable %{conf_name} in %{node_name} successfully"
 msgstr "成功在 %{node_name} 啟用 %{conf_name}"
 
+#: src/views/preference/components/TOTP.vue:122
+#, fuzzy
+msgid "Enable 2FA"
+msgstr "啟用"
+
+#: src/views/preference/components/TOTP.vue:52
+#, fuzzy
+msgid "Enable 2FA successfully"
+msgstr "啟用成功"
+
 #: src/views/domain/cert/components/ObtainCert.vue:70
 msgid "Enable auto-renewal failed for %{name}"
 msgstr "啟用 %{name} 自動續簽失敗"
@@ -878,12 +904,18 @@ msgstr "HTTP01"
 msgid "If left blank, the default CA Dir will be used."
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:60
+#: src/views/preference/AuthSettings.vue:61
 msgid ""
 "If the number of login failed attempts from a ip reach the max attempts in "
 "ban threshold minutes, the ip will be banned for a period of time."
 msgstr ""
 
+#: src/views/preference/components/TOTP.vue:108
+msgid ""
+"If you lose your mobile phone, you can use the recovery code to reset your "
+"2FA."
+msgstr ""
+
 #: src/views/certificate/Certificate.vue:136
 #, fuzzy
 msgid "Import"
@@ -894,7 +926,7 @@ msgstr "匯出"
 msgid "Import Certificate"
 msgstr "憑證狀態"
 
-#: src/views/other/Login.vue:59
+#: src/views/other/Login.vue:71
 #, fuzzy
 msgid "Incorrect username or password"
 msgstr "使用者名稱或密碼不正確"
@@ -911,7 +943,16 @@ msgstr "初始化核心升級程式錯誤"
 msgid "Initialing core upgrader"
 msgstr "正在初始化核心升級程式"
 
-#: src/routes/index.ts:273 src/views/other/Install.vue:135
+#: src/views/preference/components/TOTP.vue:144
+msgid "Input the code from the app:"
+msgstr ""
+
+#: src/components/OTP/OTPAuthorization.vue:49
+#: src/views/preference/components/TOTP.vue:157
+msgid "Input the recovery code:"
+msgstr ""
+
+#: src/routes/index.ts:283 src/views/other/Install.vue:135
 msgid "Install"
 msgstr "安裝"
 
@@ -932,7 +973,15 @@ msgstr ""
 msgid "Invalid"
 msgstr "無效的郵箱!"
 
-#: src/views/preference/AuthSettings.vue:14
+#: src/views/other/Login.vue:81
+msgid "Invalid 2FA or recovery code"
+msgstr ""
+
+#: src/components/OTP/useOTPModal.ts:43
+msgid "Invalid passcode or recovery code"
+msgstr ""
+
+#: src/views/preference/AuthSettings.vue:15
 msgid "IP"
 msgstr ""
 
@@ -1025,11 +1074,11 @@ msgstr "Locations"
 msgid "Log"
 msgstr "登入"
 
-#: src/routes/index.ts:279 src/views/other/Login.vue:159
+#: src/routes/index.ts:289 src/views/other/Login.vue:192
 msgid "Login"
 msgstr "登入"
 
-#: src/views/other/Login.vue:109 src/views/other/Login.vue:51
+#: src/views/other/Login.vue:125 src/views/other/Login.vue:60
 msgid "Login successful"
 msgstr "登入成功"
 
@@ -1071,7 +1120,7 @@ msgstr "管理網站"
 msgid "Manage Streams"
 msgstr "管理網站"
 
-#: src/routes/index.ts:230 src/views/user/User.vue:50
+#: src/routes/index.ts:235 src/views/user/User.vue:50
 msgid "Manage Users"
 msgstr "管理使用者"
 
@@ -1080,7 +1129,7 @@ msgstr "管理使用者"
 msgid "Managed Certificate"
 msgstr "更換憑證"
 
-#: src/views/preference/AuthSettings.vue:74
+#: src/views/preference/AuthSettings.vue:75
 msgid "Max Attempts"
 msgstr ""
 
@@ -1209,7 +1258,7 @@ msgstr "Nginx 重啟成功"
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:90
 #: src/views/domain/ngx_conf/LocationEditor.vue:71
 #: src/views/notification/Notification.vue:70
-#: src/views/preference/AuthSettings.vue:96
+#: src/views/preference/AuthSettings.vue:97
 #: src/views/preference/BasicSettings.vue:101
 #: src/views/stream/StreamList.vue:165
 msgid "No"
@@ -1223,7 +1272,7 @@ msgstr "Node Secret"
 msgid "Not After"
 msgstr ""
 
-#: src/routes/index.ts:285
+#: src/routes/index.ts:295
 msgid "Not Found"
 msgstr "找不到頁面"
 
@@ -1241,7 +1290,7 @@ msgstr "備註"
 msgid "Notification"
 msgstr "憑證"
 
-#: src/components/Notification/Notification.vue:82 src/routes/index.ts:221
+#: src/components/Notification/Notification.vue:82 src/routes/index.ts:226
 #, fuzzy
 msgid "Notifications"
 msgstr "憑證"
@@ -1322,7 +1371,7 @@ msgstr "覆蓋現有檔案"
 msgid "Params"
 msgstr "參數"
 
-#: src/views/other/Login.vue:144 src/views/user/User.vue:18
+#: src/views/other/Login.vue:169 src/views/user/User.vue:18
 msgid "Password"
 msgstr "密碼"
 
@@ -1348,6 +1397,10 @@ msgstr "執行核心升級錯誤"
 msgid "Performing core upgrade"
 msgstr "正在執行核心升級"
 
+#: src/components/OTP/OTPAuthorization.vue:37
+msgid "Please enter the 2FA code:"
+msgstr ""
+
 #: src/views/certificate/DNSCredential.vue:53
 msgid ""
 "Please fill in the API authentication credentials provided by your DNS "
@@ -1378,11 +1431,11 @@ msgstr "請輸入名稱,這將作為新設定的檔名!"
 msgid "Please input your E-mail!"
 msgstr "請輸入您的電子郵件!"
 
-#: src/views/other/Install.vue:45 src/views/other/Login.vue:39
+#: src/views/other/Install.vue:45 src/views/other/Login.vue:43
 msgid "Please input your password!"
 msgstr "請輸入您的密碼!"
 
-#: src/views/other/Install.vue:39 src/views/other/Login.vue:33
+#: src/views/other/Install.vue:39 src/views/other/Login.vue:37
 msgid "Please input your username!"
 msgstr "請輸入您的使用者名稱!"
 
@@ -1402,7 +1455,7 @@ msgstr "請至少選擇一個節點!"
 msgid "Pre-release"
 msgstr "預先發布"
 
-#: src/routes/index.ts:239 src/views/preference/Preference.vue:105
+#: src/routes/index.ts:244 src/views/preference/Preference.vue:105
 msgid "Preference"
 msgstr "偏好設定"
 
@@ -1445,6 +1498,19 @@ msgstr ""
 msgid "Recovered Successfully"
 msgstr "儲存成功"
 
+#: src/components/OTP/OTPAuthorization.vue:56
+#: src/views/preference/components/TOTP.vue:167
+msgid "Recovery"
+msgstr ""
+
+#: src/views/preference/components/TOTP.vue:101
+msgid "Recovery Code"
+msgstr ""
+
+#: src/views/preference/components/TOTP.vue:110
+msgid "Recovery Code:"
+msgstr ""
+
 #: src/views/preference/BasicSettings.vue:68
 #, fuzzy
 msgid "Recursive Nameservers"
@@ -1499,11 +1565,11 @@ msgstr "重新載入中"
 msgid "Reloading nginx"
 msgstr "正在重新載入 Nginx"
 
-#: src/views/preference/AuthSettings.vue:101
+#: src/views/preference/AuthSettings.vue:102
 msgid "Remove"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:47
+#: src/views/preference/AuthSettings.vue:48
 #, fuzzy
 msgid "Remove successfully"
 msgstr "儲存成功"
@@ -1548,6 +1614,11 @@ msgstr "請求參數錯誤"
 msgid "Reset"
 msgstr "重設"
 
+#: src/views/preference/components/TOTP.vue:130
+#, fuzzy
+msgid "Reset 2FA"
+msgstr "重設"
+
 #: src/components/NginxControl/NginxControl.vue:93
 msgid "Restart"
 msgstr "重新啟動"
@@ -1595,6 +1666,10 @@ msgstr "儲存成功"
 msgid "Saved successfully"
 msgstr "儲存成功"
 
+#: src/views/preference/components/TOTP.vue:91
+msgid "Scan the QR code with your mobile phone to add the account to the app."
+msgstr ""
+
 #: src/views/certificate/DNSChallenge.vue:89
 msgid "SDK"
 msgstr ""
@@ -1618,7 +1693,9 @@ msgstr "傳送"
 #: src/views/config/ConfigEdit.vue:40 src/views/domain/DomainList.vue:81
 #: src/views/environment/BatchUpgrader.vue:57
 #: src/views/environment/Environment.vue:15 src/views/other/Install.vue:69
-#: src/views/preference/AuthSettings.vue:49
+#: src/views/preference/AuthSettings.vue:50
+#: src/views/preference/components/TOTP.vue:42
+#: src/views/preference/components/TOTP.vue:55
 #: src/views/preference/Preference.vue:78 src/views/stream/StreamList.vue:113
 #: src/views/stream/StreamList.vue:81 src/views/system/Upgrade.vue:42
 msgid "Server error"
@@ -1694,7 +1771,7 @@ msgstr "SSL 憑證金鑰路徑"
 msgid "SSL Certificate Path"
 msgstr "SSL 憑證路徑"
 
-#: src/views/other/Login.vue:170
+#: src/views/other/Login.vue:203
 #, fuzzy
 msgid "SSO Login"
 msgstr "登入"
@@ -1778,7 +1855,7 @@ msgstr "更換憑證"
 msgid "Sync to"
 msgstr ""
 
-#: src/routes/index.ts:248
+#: src/routes/index.ts:253
 msgid "System"
 msgstr "系統"
 
@@ -1787,7 +1864,7 @@ msgstr "系統"
 msgid "Target"
 msgstr "目標"
 
-#: src/routes/index.ts:172 src/views/pty/Terminal.vue:91
+#: src/routes/index.ts:172 src/views/pty/Terminal.vue:114
 msgid "Terminal"
 msgstr "終端機"
 
@@ -1832,6 +1909,11 @@ msgstr "SSL 憑證金鑰路徑"
 msgid "The path exists, but the file is not a private key"
 msgstr ""
 
+#: src/views/preference/components/TOTP.vue:109
+msgid ""
+"The recovery code is only displayed once, please save it in a safe place."
+msgstr ""
+
 #: src/views/dashboard/Environments.vue:148
 msgid ""
 "The remote Nginx UI version is not compatible with the local Nginx UI "
@@ -1892,7 +1974,7 @@ msgid ""
 "This will upgrade or reinstall the Nginx UI on %{nodeNames} to %{version}."
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:59
+#: src/views/preference/AuthSettings.vue:60
 #: src/views/preference/LogrotateSettings.vue:12
 msgid "Tips"
 msgstr ""
@@ -1901,6 +1983,12 @@ msgstr ""
 msgid "Title"
 msgstr ""
 
+#: src/views/preference/components/TOTP.vue:90
+msgid ""
+"To enable it, you need to install the Google or Microsoft Authenticator app "
+"on your mobile phone."
+msgstr ""
+
 #: src/views/domain/ngx_conf/NgxConfigEditor.vue:44
 msgid ""
 "To make sure the certification auto-renewal can work normally, we need to "
@@ -1915,14 +2003,24 @@ msgstr ""
 msgid "Token is not valid"
 msgstr ""
 
-#: src/views/other/Login.vue:62
+#: src/views/other/Login.vue:74
 msgid "Too many login failed attempts, please try again later"
 msgstr ""
 
+#: src/views/preference/components/TOTP.vue:89
+msgid ""
+"TOTP is a two-factor authentication method that uses a time-based one-time "
+"password algorithm."
+msgstr ""
+
 #: src/components/StdDesign/StdDataDisplay/StdCurd.vue:209
 msgid "Trash"
 msgstr ""
 
+#: src/components/OTP/useOTPModal.ts:49
+msgid "Two-factor authentication required"
+msgstr ""
+
 #: src/views/certificate/Certificate.vue:37 src/views/config/config.ts:12
 #: src/views/notification/Notification.vue:13
 msgid "Type"
@@ -1942,7 +2040,7 @@ msgstr "更新時間"
 msgid "Updated successfully"
 msgstr "更新成功"
 
-#: src/routes/index.ts:263 src/views/environment/Environment.vue:50
+#: src/routes/index.ts:268 src/views/environment/Environment.vue:50
 #: src/views/system/Upgrade.vue:145 src/views/system/Upgrade.vue:228
 msgid "Upgrade"
 msgstr "升級"
@@ -1972,15 +2070,23 @@ msgstr "運作時間:"
 msgid "URL"
 msgstr "URL"
 
+#: src/components/OTP/OTPAuthorization.vue:69
+msgid "Use OTP"
+msgstr ""
+
+#: src/components/OTP/OTPAuthorization.vue:65
+msgid "Use recovery code"
+msgstr ""
+
 #: src/components/ChatGPT/ChatGPT.vue:229
 msgid "User"
 msgstr "使用者名稱"
 
-#: src/views/other/Login.vue:65
+#: src/views/other/Login.vue:77
 msgid "User is banned"
 msgstr ""
 
-#: src/views/other/Login.vue:134 src/views/user/User.vue:9
+#: src/views/other/Login.vue:159 src/views/user/User.vue:9
 msgid "Username"
 msgstr "使用者名稱"
 
@@ -2052,7 +2158,7 @@ msgstr "將憑證寫入磁碟"
 
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:89
 #: src/views/domain/ngx_conf/LocationEditor.vue:70
-#: src/views/preference/AuthSettings.vue:95
+#: src/views/preference/AuthSettings.vue:96
 #: src/views/preference/BasicSettings.vue:100
 msgid "Yes"
 msgstr "是的"

+ 60 - 27
app/src/views/other/Login.vue

@@ -1,13 +1,13 @@
 <script setup lang="ts">
 import { LockOutlined, UserOutlined } from '@ant-design/icons-vue'
 import { Form, message } from 'ant-design-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 from '@/gettext'
+import gettext, { $gettext } from '@/gettext'
+import OTPAuthorization from '@/components/OTP/OTPAuthorization.vue'
 
 const thisYear = new Date().getFullYear()
 
@@ -20,6 +20,10 @@ install.get_lock().then(async (r: { lock: boolean }) => {
 })
 
 const loading = ref(false)
+const enabled2FA = ref(false)
+const refOTP = ref()
+const passcode = ref('')
+const recoveryCode = ref('')
 
 const modelRef = reactive({
   username: '',
@@ -42,17 +46,25 @@ const rulesRef = reactive({
 })
 
 const { validate, validateInfos, clearValidate } = Form.useForm(modelRef, rulesRef)
+const { login } = useUserStore()
 
 const onSubmit = () => {
   validate().then(async () => {
     loading.value = true
 
-    await auth.login(modelRef.username, modelRef.password).then(async () => {
-      message.success($gettext('Login successful'), 1)
-
+    await auth.login(modelRef.username, modelRef.password, passcode.value, recoveryCode.value).then(async r => {
       const next = (route.query?.next || '').toString() || '/'
 
-      await router.push(next)
+      switch (r.code) {
+        case 200:
+          message.success($gettext('Login successful'), 1)
+          login(r.token)
+          await router.push(next)
+          break
+        case 199:
+          enabled2FA.value = true
+          break
+      }
     }).catch(e => {
       switch (e.code) {
         case 4031:
@@ -64,6 +76,10 @@ const onSubmit = () => {
         case 4033:
           message.error($gettext('User is banned'))
           break
+        case 4034:
+          refOTP.value?.clearInput()
+          message.error($gettext('Invalid 2FA or recovery code'))
+          break
         default:
           message.error($gettext(e.message ?? 'Server error'))
           break
@@ -117,6 +133,14 @@ if (route.query?.code !== undefined && route.query?.state !== undefined) {
   loading.value = false
 }
 
+function handleOTPSubmit(code: string, recovery: string) {
+  passcode.value = code
+  recoveryCode.value = recovery
+
+  nextTick(() => {
+    onSubmit()
+  })
+}
 </script>
 
 <template>
@@ -128,27 +152,36 @@ if (route.query?.code !== undefined && route.query?.state !== undefined) {
             <h1>Nginx UI</h1>
           </div>
           <AForm id="components-form-demo-normal-login">
-            <AFormItem v-bind="validateInfos.username">
-              <AInput
-                v-model:value="modelRef.username"
-                :placeholder="$gettext('Username')"
-              >
-                <template #prefix>
-                  <UserOutlined style="color: rgba(0, 0, 0, 0.25)" />
-                </template>
-              </AInput>
-            </AFormItem>
-            <AFormItem v-bind="validateInfos.password">
-              <AInputPassword
-                v-model:value="modelRef.password"
-                :placeholder="$gettext('Password')"
-              >
-                <template #prefix>
-                  <LockOutlined style="color: rgba(0, 0, 0, 0.25)" />
-                </template>
-              </AInputPassword>
-            </AFormItem>
-            <AFormItem>
+            <template v-if="!enabled2FA">
+              <AFormItem v-bind="validateInfos.username">
+                <AInput
+                  v-model:value="modelRef.username"
+                  :placeholder="$gettext('Username')"
+                >
+                  <template #prefix>
+                    <UserOutlined style="color: rgba(0, 0, 0, 0.25)" />
+                  </template>
+                </AInput>
+              </AFormItem>
+              <AFormItem v-bind="validateInfos.password">
+                <AInputPassword
+                  v-model:value="modelRef.password"
+                  :placeholder="$gettext('Password')"
+                >
+                  <template #prefix>
+                    <LockOutlined style="color: rgba(0, 0, 0, 0.25)" />
+                  </template>
+                </AInputPassword>
+              </AFormItem>
+            </template>
+            <div v-else>
+              <OTPAuthorization
+                ref="refOTP"
+                @on-submit="handleOTPSubmit"
+              />
+            </div>
+
+            <AFormItem v-if="!enabled2FA">
               <AButton
                 type="primary"
                 block

+ 3 - 0
app/src/views/preference/AuthSettings.vue

@@ -7,6 +7,7 @@ import type { BannedIP } from '@/api/settings'
 import setting from '@/api/settings'
 import type { customRender } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
 import type { Settings } from '@/views/preference/typedef'
+import TOTP from '@/views/preference/components/TOTP.vue'
 
 const data: Settings = inject('data') as Settings
 
@@ -54,6 +55,8 @@ function removeBannedIP(ip: string) {
 <template>
   <div class="flex justify-center">
     <div>
+      <TOTP class="mb-4" />
+
       <AAlert
         class="mb-4"
         :message="$gettext('Tips')"

+ 176 - 0
app/src/views/preference/components/TOTP.vue

@@ -0,0 +1,176 @@
+<script setup lang="ts">
+import { message } from 'ant-design-vue'
+import { CheckCircleOutlined } from '@ant-design/icons-vue'
+import otp from '@/api/otp'
+import OTPInput from '@/components/OTPInput/OTPInput.vue'
+
+const status = ref(false)
+const enrolling = ref(false)
+const resetting = ref(false)
+const qrCode = ref('')
+const secret = ref('')
+const passcode = ref('')
+const interval = ref()
+const refOtp = ref()
+const recoveryCode = ref('')
+const inputRecoveryCode = ref('')
+
+function clickEnable2FA() {
+  enrolling.value = true
+  generateSecret()
+  interval.value = setInterval(() => {
+    if (enrolling.value)
+      generateSecret()
+    else
+      clearGenerateSecretInterval()
+  }, 30 * 1000)
+}
+
+function clearGenerateSecretInterval() {
+  if (interval.value) {
+    clearInterval(interval.value)
+    interval.value = undefined
+  }
+}
+
+function generateSecret() {
+  otp.generate_secret().then(r => {
+    secret.value = r.secret
+    qrCode.value = r.qr_code
+    refOtp.value?.clearInput()
+  }).catch((e: { message?: string }) => {
+    message.error(e.message ?? $gettext('Server error'))
+  })
+}
+
+function enroll(code: string) {
+  otp.enroll_otp(secret.value, code).then(r => {
+    enrolling.value = false
+    recoveryCode.value = r.recovery_code
+    clearGenerateSecretInterval()
+    get2FAStatus()
+    message.success($gettext('Enable 2FA successfully'))
+  }).catch((e: { message?: string }) => {
+    refOtp.value?.clearInput()
+    message.error(e.message ?? $gettext('Server error'))
+  })
+}
+
+function get2FAStatus() {
+  otp.status().then(r => {
+    status.value = r.status
+  })
+}
+
+get2FAStatus()
+
+onUnmounted(clearGenerateSecretInterval)
+
+function clickReset2FA() {
+  resetting.value = true
+  inputRecoveryCode.value = ''
+}
+
+function reset2FA() {
+  otp.reset(inputRecoveryCode.value).then(() => {
+    resetting.value = false
+    recoveryCode.value = ''
+    get2FAStatus()
+    clickEnable2FA()
+  }).catch((e: { message?: string }) => {
+    message.error($gettext(e.message ?? 'Server error'))
+  })
+}
+</script>
+
+<template>
+  <div>
+    <h3>{{ $gettext('2FA Settings') }}</h3>
+    <p>{{ $gettext('TOTP is a two-factor authentication method that uses a time-based one-time password algorithm.') }}</p>
+    <p>{{ $gettext('To enable it, you need to install the Google or Microsoft Authenticator app on your mobile phone.') }}</p>
+    <p>{{ $gettext('Scan the QR code with your mobile phone to add the account to the app.') }}</p>
+    <p v-if="!status">
+      {{ $gettext('Current account is not enabled 2FA.') }}
+    </p>
+    <div v-else>
+      <p><CheckCircleOutlined class="mr-2 text-green-600" />{{ $gettext('Current account is enabled 2FA.') }}</p>
+    </div>
+
+    <AAlert
+      v-if="recoveryCode"
+      :message="$gettext('Recovery Code')"
+      class="mb-4"
+      type="info"
+      show-icon
+    >
+      <template #description>
+        <div>
+          <p>{{ $gettext('If you lose your mobile phone, you can use the recovery code to reset your 2FA.') }}</p>
+          <p>{{ $gettext('The recovery code is only displayed once, please save it in a safe place.') }}</p>
+          <p>{{ $gettext('Recovery Code:') }}</p>
+          <span class="ml-2">{{ recoveryCode }}</span>
+        </div>
+      </template>
+    </AAlert>
+
+    <AButton
+      v-if="!status && !enrolling"
+      type="primary"
+      ghost
+      @click="clickEnable2FA"
+    >
+      {{ $gettext('Enable 2FA') }}
+    </AButton>
+    <AButton
+      v-if="status && !resetting"
+      type="primary"
+      ghost
+      @click="clickReset2FA"
+    >
+      {{ $gettext('Reset 2FA') }}
+    </AButton>
+
+    <template v-if="enrolling">
+      <div class="w-64 h-64 mt-4 mb-2">
+        <img
+          v-if="qrCode"
+          class="w-full"
+          :src="qrCode"
+          alt="qr code"
+        >
+      </div>
+
+      <div>
+        <p>{{ $gettext('Input the code from the app:') }}</p>
+        <OTPInput
+          ref="refOtp"
+          v-model="passcode"
+          @on-complete="enroll"
+        />
+      </div>
+    </template>
+
+    <div
+      v-if="resetting"
+      class="mt-2"
+    >
+      <p>{{ $gettext('Input the recovery code:') }}</p>
+      <AInputGroup compact>
+        <AInput
+          v-model:value="inputRecoveryCode"
+          style="width: calc(100% - 92px)"
+        />
+        <AButton
+          type="primary"
+          @click="reset2FA"
+        >
+          {{ $gettext('Recovery') }}
+        </AButton>
+      </AInputGroup>
+    </div>
+  </div>
+</template>
+
+<style scoped lang="less">
+
+</style>

+ 38 - 8
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()
-
-  websocket.onmessage = wsOnMessage
-  websocket.onopen = wsOnOpen
+  const otpModal = useOTPModal()
+
+  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"

+ 2 - 1
docs/.vitepress/config/en.ts

@@ -40,7 +40,8 @@ export const enConfig: LocaleSpecificConfig<DefaultTheme.Config> = {
             {text: 'Casdoor', link: '/guide/config-casdoor'},
             {text: 'Logrotate', link: '/guide/config-logrotate'},
             {text: 'Cluster', link: '/guide/config-cluster'},
-            {text: 'Auth', link: '/guide/config-auth'}
+            {text: 'Auth', link: '/guide/config-auth'},
+            {text: 'Crypto', link: '/guide/config-crypto'}
           ]
         },
         {

+ 2 - 1
docs/.vitepress/config/zh_CN.ts

@@ -45,7 +45,8 @@ export const zhCNConfig: LocaleSpecificConfig<DefaultTheme.Config> = {
             {text: 'Casdoor', link: '/zh_CN/guide/config-casdoor'},
             {text: 'Logrotate', link: '/zh_CN/guide/config-logrotate'},
             {text: '集群', link: '/zh_CN/guide/config-cluster'},
-            {text: '认证', link: '/zh_CN/guide/config-auth'}
+            {text: '认证', link: '/zh_CN/guide/config-auth'},
+            {text: '加密', link: '/zh_CN/guide/config-crypto'}
           ]
         },
         {

+ 2 - 1
docs/.vitepress/config/zh_TW.ts

@@ -44,7 +44,8 @@ export const zhTWConfig: LocaleSpecificConfig<DefaultTheme.Config> = {
             {text: 'Casdoor', link: '/zh_TW/guide/config-casdoor'},
             {text: 'Logrotate', link: '/zh_TW/guide/config-logrotate'},
             {text: '集群', link: '/zh_TW/guide/config-cluster'},
-            {text: '認證', link: '/zh_TM/guide/config-auth'}
+            {text: '認證', link: '/zh_TW/guide/config-auth'},
+            {text: '加密', link: '/zh_TW/guide/config-crypto'}
           ]
         },
         {

+ 7 - 0
docs/guide/config-crypto.md

@@ -0,0 +1,7 @@
+# Crypto
+
+## Secret
+- Type: `string`
+
+If this value is empty, Nginx UI will generate a random secret key automatically.
+This secret is used to encrypt the sensitive data stored in the database.

+ 3 - 0
docs/index.md

@@ -51,6 +51,9 @@ features:
     -   icon: 📱
         title: Responsive Web Design
         details: Enjoy a seamless experience on any device with responsive web design.
+    -   icon: 🔐
+        title: 2FA Authentication
+        details: Secure sensitive actions with two-factor authentication.
 
 ---
 

+ 6 - 0
docs/zh_CN/guide/config-crypto.md

@@ -0,0 +1,6 @@
+# Crypto
+
+## Secret
+- Type: `string`
+
+如果这个值为空,Nginx UI 将会自动生成一个随机的密钥。这个密钥用于加密存储在数据库中的敏感数据。

+ 3 - 1
docs/zh_CN/index.md

@@ -51,6 +51,8 @@ features:
     -   icon: 📱
         title: 自适应网页设计
         details: 通过自适应网页设计在任何设备上享受无缝体验。
-
+    -   icon: 🔐
+        title: 双因素认证
+        details: 使用双因素认证保护敏感操作。
 ---
 

+ 6 - 0
docs/zh_TW/guide/config-crypto.md

@@ -0,0 +1,6 @@
+# Crypto
+
+## Secret
+- Type: `string`
+
+如果這個值為空,Nginx UI 將會自動生成一個隨機的密鑰。這個密鑰用於加密存儲在數據庫中的敏感數據。

+ 3 - 1
docs/zh_TW/index.md

@@ -51,6 +51,8 @@ features:
     -   icon: 📱
         title: 自適應網頁設計
         details: 透過自適應網頁設計在任何裝置上享受無縫體驗。
-
+    -   icon: 🔐
+        title: 雙因素認證
+        details: 使用雙因素認證保護敏感操作。
 ---
 

+ 17 - 12
go.mod

@@ -8,6 +8,7 @@ require (
 	github.com/caarlos0/env/v11 v11.1.0
 	github.com/casdoor/casdoor-go-sdk v0.47.0
 	github.com/creack/pty v1.1.21
+	github.com/dgraph-io/ristretto v0.1.1
 	github.com/dgrijalva/jwt-go v3.2.0+incompatible
 	github.com/dustin/go-humanize v1.0.1
 	github.com/fatih/color v1.17.0
@@ -44,7 +45,7 @@ require (
 
 require (
 	aead.dev/minisign v0.3.0 // indirect
-	cloud.google.com/go/auth v0.7.1 // indirect
+	cloud.google.com/go/auth v0.7.2 // indirect
 	cloud.google.com/go/auth/oauth2adapt v0.2.3 // indirect
 	cloud.google.com/go/compute/metadata v0.5.0 // indirect
 	filippo.io/edwards25519 v1.1.0 // indirect
@@ -73,7 +74,7 @@ require (
 	github.com/Shopify/goreferrer v0.0.0-20220729165902-8cddb4f5de06 // indirect
 	github.com/StackExchange/wmi v1.2.1 // indirect
 	github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2 // indirect
-	github.com/aliyun/alibaba-cloud-sdk-go v1.62.793 // indirect
+	github.com/aliyun/alibaba-cloud-sdk-go v1.62.795 // indirect
 	github.com/andybalholm/brotli v1.1.0 // indirect
 	github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
 	github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect
@@ -97,6 +98,8 @@ require (
 	github.com/bytedance/sonic v1.11.9 // indirect
 	github.com/bytedance/sonic/loader v0.1.1 // indirect
 	github.com/cenkalti/backoff/v4 v4.3.0 // indirect
+	github.com/cespare/xxhash v1.1.0 // indirect
+	github.com/cespare/xxhash/v2 v2.3.0 // indirect
 	github.com/civo/civogo v0.3.73 // indirect
 	github.com/cloudflare/cloudflare-go v0.100.0 // indirect
 	github.com/cloudwego/base64x v0.1.4 // indirect
@@ -125,20 +128,21 @@ require (
 	github.com/go-sql-driver/mysql v1.8.1 // indirect
 	github.com/go-viper/mapstructure/v2 v2.0.0 // indirect
 	github.com/goccy/go-json v0.10.3 // indirect
-	github.com/gofrs/flock v0.12.0 // indirect
+	github.com/gofrs/flock v0.12.1 // indirect
 	github.com/gofrs/uuid v4.4.0+incompatible // indirect
 	github.com/gogo/protobuf v1.3.2 // indirect
 	github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
 	github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
+	github.com/golang/glog v1.2.2 // indirect
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
 	github.com/golang/protobuf v1.5.4 // indirect
 	github.com/golang/snappy v0.0.4 // indirect
 	github.com/gomarkdown/markdown v0.0.0-20240626202925-2eda941fd024 // indirect
 	github.com/google/go-querystring v1.1.0 // indirect
 	github.com/google/gofuzz v1.2.0 // indirect
-	github.com/google/s2a-go v0.1.7 // indirect
+	github.com/google/s2a-go v0.1.8 // indirect
 	github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
-	github.com/googleapis/gax-go/v2 v2.12.5 // indirect
+	github.com/googleapis/gax-go/v2 v2.13.0 // indirect
 	github.com/gophercloud/gophercloud v1.13.0 // indirect
 	github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56 // indirect
 	github.com/gorilla/css v1.0.1 // indirect
@@ -228,8 +232,8 @@ require (
 	github.com/stretchr/objx v0.5.2 // indirect
 	github.com/tdewolff/minify/v2 v2.20.37 // indirect
 	github.com/tdewolff/parse/v2 v2.7.15 // indirect
-	github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.968 // indirect
-	github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.968 // indirect
+	github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.969 // indirect
+	github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.969 // indirect
 	github.com/tklauser/go-sysconf v0.3.14 // indirect
 	github.com/tklauser/numcpus v0.8.0 // indirect
 	github.com/transip/gotransip/v6 v6.25.0 // indirect
@@ -242,7 +246,7 @@ require (
 	github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
 	github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
 	github.com/vultr/govultr/v2 v2.17.2 // indirect
-	github.com/yandex-cloud/go-genproto v0.0.0-20240715115219-0c1e192fbf5c // indirect
+	github.com/yandex-cloud/go-genproto v0.0.0-20240722173647-40d4f9e8b9fa // indirect
 	github.com/yandex-cloud/go-sdk v0.0.0-20240701143239-7326d2d09169 // indirect
 	github.com/yosssi/ace v0.0.5 // indirect
 	github.com/yusufpapurcu/wmi v1.2.4 // indirect
@@ -264,10 +268,10 @@ require (
 	golang.org/x/text v0.16.0 // indirect
 	golang.org/x/time v0.5.0 // indirect
 	golang.org/x/tools v0.23.0 // indirect
-	google.golang.org/api v0.188.0 // indirect
-	google.golang.org/genproto v0.0.0-20240711142825-46eb208f015d // indirect
-	google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20240711142825-46eb208f015d // indirect
+	google.golang.org/api v0.189.0 // indirect
+	google.golang.org/genproto v0.0.0-20240722135656-d784300faade // indirect
+	google.golang.org/genproto/googleapis/api v0.0.0-20240722135656-d784300faade // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade // indirect
 	google.golang.org/grpc v1.65.0 // indirect
 	google.golang.org/protobuf v1.34.2 // indirect
 	gopkg.in/fsnotify.v1 v1.4.7 // indirect
@@ -278,6 +282,7 @@ require (
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 	gorm.io/datatypes v1.2.1 // indirect
 	gorm.io/driver/mysql v1.5.7 // indirect
+	gorm.io/driver/postgres v1.5.6 // indirect
 	gorm.io/hints v1.1.2 // indirect
 	k8s.io/api v0.30.3 // indirect
 	k8s.io/apimachinery v0.30.3 // indirect

+ 42 - 10
go.sum

@@ -101,8 +101,8 @@ cloud.google.com/go/assuredworkloads v1.7.0/go.mod h1:z/736/oNmtGAyU47reJgGN+KVo
 cloud.google.com/go/assuredworkloads v1.8.0/go.mod h1:AsX2cqyNCOvEQC8RMPnoc0yEarXQk6WEKkxYfL6kGIo=
 cloud.google.com/go/assuredworkloads v1.9.0/go.mod h1:kFuI1P78bplYtT77Tb1hi0FMxM0vVpRC7VVoJC3ZoT0=
 cloud.google.com/go/assuredworkloads v1.10.0/go.mod h1:kwdUQuXcedVdsIaKgKTp9t0UJkE5+PAVNhdQm4ZVq2E=
-cloud.google.com/go/auth v0.7.1 h1:Iv1bbpzJ2OIg16m94XI9/tlzZZl3cdeR3nGVGj78N7s=
-cloud.google.com/go/auth v0.7.1/go.mod h1:VEc4p5NNxycWQTMQEDQF0bd6aTMb6VgYDXEwiJJQAbs=
+cloud.google.com/go/auth v0.7.2 h1:uiha352VrCDMXg+yoBtaD0tUF4Kv9vrtrWPYXwutnDE=
+cloud.google.com/go/auth v0.7.2/go.mod h1:VEc4p5NNxycWQTMQEDQF0bd6aTMb6VgYDXEwiJJQAbs=
 cloud.google.com/go/auth/oauth2adapt v0.2.3 h1:MlxF+Pd3OmSudg/b1yZ5lJwoXCEaeedAguodky1PcKI=
 cloud.google.com/go/auth/oauth2adapt v0.2.3/go.mod h1:tMQXOfZzFuNuUxOypHlQEXgdfX5cuhwU+ffUuXRJE8I=
 cloud.google.com/go/automl v1.5.0/go.mod h1:34EjfoFGMZ5sgJ9EoLsRtdPSNZLcfflJR39VbVNS2M0=
@@ -672,6 +672,7 @@ github.com/Joker/hpp v1.0.0 h1:65+iuJYdRXv/XyN62C1uEmmOx3432rNG/rKlX6V7Kkc=
 github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY=
 github.com/Joker/jade v1.1.3 h1:Qbeh12Vq6BxURXT1qZBRHsDxeURB8ztcL6f3EXSGeHk=
 github.com/Joker/jade v1.1.3/go.mod h1:T+2WLyt7VH6Lp0TRxQrUYEs64nRc83wkMQrfeIQKduM=
+github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
 github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
 github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87 h1:xPMsUicZ3iosVPSIP7bW5EcGUzjiiMl1OYTe14y/R24=
 github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87/go.mod h1:iGLljf5n9GjT6kc0HBvyI1nOKnGQbNB66VzSNbK5iks=
@@ -694,8 +695,10 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy
 github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
 github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
 github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
-github.com/aliyun/alibaba-cloud-sdk-go v1.62.793 h1:7FmdfF5fZMxM8Y0YtwrnMLkwud+egvoB5X5xczqISNQ=
-github.com/aliyun/alibaba-cloud-sdk-go v1.62.793/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
+github.com/aliyun/alibaba-cloud-sdk-go v1.62.794 h1:M6YtlJdCobRVlJaILK4Eia5aMtDSpeQtxFRl4hSi+DU=
+github.com/aliyun/alibaba-cloud-sdk-go v1.62.794/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
+github.com/aliyun/alibaba-cloud-sdk-go v1.62.795 h1:DjIaInK6Ru+fPnOX0Ef4ux5tkp/dCPI3pAZEijEvlvo=
+github.com/aliyun/alibaba-cloud-sdk-go v1.62.795/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
 github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
 github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
 github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
@@ -772,9 +775,12 @@ github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyY
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
 github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
 github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw=
+github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
 github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
 github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
@@ -821,8 +827,14 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/deepmap/oapi-codegen v1.16.3 h1:GT9G86SbQtT1r8ZB+4Cybi9VGdu1P5ieNvNdEoCSbrA=
 github.com/deepmap/oapi-codegen v1.16.3/go.mod h1:JD6ErqeX0nYnhdciLc61Konj3NBASREMlkHOgHn8WAM=
+github.com/dgraph-io/ristretto v0.0.3-0.20200630154024-f66de99634de h1:t0UHb5vdojIDUqktM6+xJAfScFBsVpXZmqC9dsgJmeA=
+github.com/dgraph-io/ristretto v0.0.3-0.20200630154024-f66de99634de/go.mod h1:KPxhHT9ZxKefz+PCeOGsrHpl1qZ7i70dGTu2u+Ahh6E=
+github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8=
+github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA=
 github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
 github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
+github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA=
+github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
 github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
 github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U=
 github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE=
@@ -949,6 +961,8 @@ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5x
 github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
 github.com/gofrs/flock v0.12.0 h1:xHW8t8GPAiGtqz7KxiSqfOEXwpOaqhpYZrTE2MQBgXY=
 github.com/gofrs/flock v0.12.0/go.mod h1:FirDy1Ing0mI2+kB6wk+vyyAH+e6xiE+EYA0jnzV9jc=
+github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
+github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
 github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
 github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
 github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
@@ -972,6 +986,8 @@ github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGw
 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
 github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4=
 github.com/golang/glog v1.1.0/go.mod h1:pfYeQZ3JWZoXTV5sFc986z3HTpwQs9At6P4ImfuP3NQ=
+github.com/golang/glog v1.2.2 h1:1+mZ9upx1Dh6FmUTFR1naJ77miKiXgALjWOZ3NVFPmY=
+github.com/golang/glog v1.2.2/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
 github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -1065,6 +1081,8 @@ github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm4
 github.com/google/s2a-go v0.1.3/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A=
 github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
 github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
+github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
+github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
 github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -1092,6 +1110,8 @@ github.com/googleapis/gax-go/v2 v2.7.1/go.mod h1:4orTrqY6hXxxaUL4LHIPl6lGo8vAE38
 github.com/googleapis/gax-go/v2 v2.8.0/go.mod h1:4orTrqY6hXxxaUL4LHIPl6lGo8vAE38/qKbhSAKP6QI=
 github.com/googleapis/gax-go/v2 v2.12.5 h1:8gw9KZK8TiVKB6q3zHY3SBzLnrGp6HQjyfYBYGmXdxA=
 github.com/googleapis/gax-go/v2 v2.12.5/go.mod h1:BUDKcWo+RaKq5SC9vVYL0wLADa3VcfswbOMMRmB9H3E=
+github.com/googleapis/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s=
+github.com/googleapis/gax-go/v2 v2.13.0/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A=
 github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4=
 github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
 github.com/gophercloud/gophercloud v1.3.0/go.mod h1:aAVqcocTSXh2vYFZ1JTvx4EQmfgzxRcNupUfxZbBNDM=
@@ -1561,6 +1581,7 @@ github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4k
 github.com/sony/gobreaker v0.5.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
 github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ=
 github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
+github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 h1:qLC7fQah7D6K1B0ujays3HV9gkFtllcxhzImRR7ArPQ=
 github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
 github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
 github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4=
@@ -1614,14 +1635,14 @@ github.com/tdewolff/parse/v2 v2.7.15/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W
 github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
 github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 h1:IkjBCtQOOjIn03u/dMQK9g+Iw9ewps4mCl1nB8Sscbo=
 github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
-github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.967 h1:ui73H/2pKk2aDCxaBCLAeMB3JlNgdCkn0nx1x0pqvf0=
-github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.967/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
 github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.968 h1:SdgunZB3WU2vNn3H9dJQ1Z2cQK61vN79zCfnHk3Cu3Y=
 github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.968/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
-github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.967 h1:4w33xHFgyrlFZYoGkPQ3uhld8tqoezpObfmCBrdlFBY=
-github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.967/go.mod h1:T0RlPIT2imBeCxLkWfzoiEVP1r5WwzC6becSq7wvSgU=
+github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.969 h1:rJlV77WbjuJ5uGBi+THOk09Cfp8Kskz9HgExq0enTmY=
+github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.969/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
 github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.968 h1:h7voJALWRkUX6w7obk9CWHppnJwZuQlreQJVDldVRxY=
 github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.968/go.mod h1:3cwvPwyqYaYkzAsR4vbrE6mb3Ju9uY7Pj+wHYSVd3aw=
+github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.969 h1:W2DHKBCSLjpHoQjqgAkyUu7lV8deIW+FBZS95iNRf1A=
+github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.969/go.mod h1:jIxuhjYsAyTTErdwvaX1ay+FHH021fmjdlsbnkaOgfs=
 github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU=
 github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY=
 github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYgY=
@@ -1664,6 +1685,8 @@ github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmv
 github.com/yandex-cloud/go-genproto v0.0.0-20240701142715-6a03f33f8ec8/go.mod h1:HEUYX/p8966tMUHHT+TsS0hF/Ca/NYwqprC5WXSDMfE=
 github.com/yandex-cloud/go-genproto v0.0.0-20240715115219-0c1e192fbf5c h1:GzMfpQ/oAP93MOQb5/B+3daDzdcLRRqetZ8radtnJJ4=
 github.com/yandex-cloud/go-genproto v0.0.0-20240715115219-0c1e192fbf5c/go.mod h1:HEUYX/p8966tMUHHT+TsS0hF/Ca/NYwqprC5WXSDMfE=
+github.com/yandex-cloud/go-genproto v0.0.0-20240722173647-40d4f9e8b9fa h1:MFb4Q81BMqa0vL64v/i3mel9C+XQkVnwgWqWbmqv10U=
+github.com/yandex-cloud/go-genproto v0.0.0-20240722173647-40d4f9e8b9fa/go.mod h1:HEUYX/p8966tMUHHT+TsS0hF/Ca/NYwqprC5WXSDMfE=
 github.com/yandex-cloud/go-sdk v0.0.0-20240701143239-7326d2d09169 h1:5LGYQ/0h1uUo3HH8MsG6R40gvSVPj/7r4D1sKVMa370=
 github.com/yandex-cloud/go-sdk v0.0.0-20240701143239-7326d2d09169/go.mod h1:kRqpmRyPs8rzXuYEJe57AH546a3VcSjEIzdFa1V66hY=
 github.com/yosssi/ace v0.0.5 h1:tUkIP/BLdKqrlrPwcmH0shwEEhTRHoGnc1wFIWmaBUA=
@@ -2051,6 +2074,7 @@ golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220829200755-d48e67d00261/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -2258,6 +2282,8 @@ google.golang.org/api v0.114.0/go.mod h1:ifYI2ZsFK6/uGddGfAD5BMxlnkBqCmqHSDUVi45
 google.golang.org/api v0.122.0/go.mod h1:gcitW0lvnyWjSp9nKxAbdHKIZ6vF4aajGueeslZOyms=
 google.golang.org/api v0.188.0 h1:51y8fJ/b1AaaBRJr4yWm96fPcuxSo0JcegXE3DaHQHw=
 google.golang.org/api v0.188.0/go.mod h1:VR0d+2SIiWOYG3r/jdm7adPW9hI2aRv9ETOSCQ9Beag=
+google.golang.org/api v0.189.0 h1:equMo30LypAkdkLMBqfeIqtyAnlyig1JSZArl4XPwdI=
+google.golang.org/api v0.189.0/go.mod h1:FLWGJKb0hb+pU2j+rJqwbnsF+ym+fQs73rbJ+KAUgy8=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -2400,10 +2426,16 @@ google.golang.org/genproto v0.0.0-20230331144136-dcfb400f0633/go.mod h1:UUQDJDOl
 google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU=
 google.golang.org/genproto v0.0.0-20240711142825-46eb208f015d h1:/hmn0Ku5kWij/kjGsrcJeC1T/MrJi2iNWwgAqrihFwc=
 google.golang.org/genproto v0.0.0-20240711142825-46eb208f015d/go.mod h1:FfBgJBJg9GcpPvKIuHSZ/aE1g2ecGL74upMzGZjiGEY=
+google.golang.org/genproto v0.0.0-20240722135656-d784300faade h1:lKFsS7wpngDgSCeFn7MoLy+wBDQZ1UQIJD4UNM1Qvkg=
+google.golang.org/genproto v0.0.0-20240722135656-d784300faade/go.mod h1:FfBgJBJg9GcpPvKIuHSZ/aE1g2ecGL74upMzGZjiGEY=
 google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d h1:kHjw/5UfflP/L5EbledDrcG4C2597RtymmGRZvHiCuY=
 google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d/go.mod h1:mw8MG/Qz5wfgYr6VqVCiZcHe/GJEfI+oGGDCohaVgB0=
+google.golang.org/genproto/googleapis/api v0.0.0-20240722135656-d784300faade h1:WxZOF2yayUHpHSbUE6NMzumUzBxYc3YGwo0YHnbzsJY=
+google.golang.org/genproto/googleapis/api v0.0.0-20240722135656-d784300faade/go.mod h1:mw8MG/Qz5wfgYr6VqVCiZcHe/GJEfI+oGGDCohaVgB0=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20240711142825-46eb208f015d h1:JU0iKnSg02Gmb5ZdV8nYsKEKsP6o/FGVWTrw4i1DA9A=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20240711142825-46eb208f015d/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade h1:oCRSWfwGXQsqlVdErcyTt4A93Y8fo0/9D4b1gnI++qo=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
 google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
@@ -2515,8 +2547,8 @@ gorm.io/datatypes v1.2.1/go.mod h1:hYK6OTb/1x+m96PgoZZq10UXJ6RvEBb9kRDQ2yyhzGs=
 gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
 gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
 gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
-gorm.io/driver/postgres v1.5.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U=
-gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A=
+gorm.io/driver/postgres v1.5.6 h1:ydr9xEd5YAM0vxVDY0X139dyzNz10spDiDlC7+ibLeU=
+gorm.io/driver/postgres v1.5.6/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA=
 gorm.io/driver/sqlite v1.5.0/go.mod h1:kDMDfntV9u/vuMmz8APHtHF0b4nyBB7sfCieC6G8k8I=
 gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE=
 gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=

+ 31 - 0
internal/cache/cache.go

@@ -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)
+}

+ 14 - 2
internal/cron/cron.go

@@ -4,6 +4,7 @@ import (
 	"github.com/0xJacky/Nginx-UI/internal/cert"
 	"github.com/0xJacky/Nginx-UI/internal/logger"
 	"github.com/0xJacky/Nginx-UI/internal/logrotate"
+	"github.com/0xJacky/Nginx-UI/query"
 	"github.com/0xJacky/Nginx-UI/settings"
 	"github.com/go-co-op/gocron"
 	"time"
@@ -25,6 +26,7 @@ func InitCronJobs() {
 	}
 
 	startLogrotate()
+	cleanExpiredAuthToken()
 
 	s.StartAsync()
 }
@@ -43,10 +45,20 @@ func startLogrotate() {
 		return
 	}
 	var err error
-
 	logrotateJob, err = s.Every(settings.LogrotateSettings.Interval).Minute().SingletonMode().Do(logrotate.Exec)
-
 	if err != nil {
 		logger.Fatalf("LogRotate Job: %v, Err: %v\n", logrotateJob, err)
 	}
 }
+
+func cleanExpiredAuthToken() {
+	job, err := s.Every(5).Minute().SingletonMode().Do(func() {
+		logger.Info("clean expired auth tokens")
+		q := query.AuthToken
+		_, _ = q.Where(q.ExpiredAt.Lt(time.Now().Unix())).Delete()
+	})
+
+	if err != nil {
+		logger.Fatalf("CleanExpiredAuthToken Job: %v, Err: %v\n", job, err)
+	}
+}

+ 59 - 0
internal/crypto/aes.go

@@ -0,0 +1,59 @@
+package crypto
+
+import (
+	"crypto/aes"
+	"crypto/cipher"
+	"crypto/rand"
+	"encoding/base64"
+	"fmt"
+	"github.com/0xJacky/Nginx-UI/settings"
+	"github.com/pkg/errors"
+	"io"
+)
+
+// AesEncrypt encrypts text and given key with AES.
+func AesEncrypt(text []byte) ([]byte, error) {
+	if len(text) == 0 {
+		return nil, errors.New("AesEncrypt text is empty")
+	}
+	block, err := aes.NewCipher(settings.CryptoSettings.GetSecretMd5())
+	if err != nil {
+		return nil, fmt.Errorf("AesEncrypt invalid key: %v", err)
+	}
+
+	b := base64.StdEncoding.EncodeToString(text)
+	ciphertext := make([]byte, aes.BlockSize+len(b))
+	iv := ciphertext[:aes.BlockSize]
+	if _, err = io.ReadFull(rand.Reader, iv); err != nil {
+		return nil, fmt.Errorf("AesEncrypt unable to read IV: %w", err)
+	}
+
+	cfb := cipher.NewCFBEncrypter(block, iv)
+	cfb.XORKeyStream(ciphertext[aes.BlockSize:], []byte(b))
+
+	return ciphertext, nil
+}
+
+// AesDecrypt decrypts text and given key with AES.
+func AesDecrypt(text []byte) ([]byte, error) {
+	block, err := aes.NewCipher(settings.CryptoSettings.GetSecretMd5())
+	if err != nil {
+		return nil, err
+	}
+
+	if len(text) < aes.BlockSize {
+		return nil, errors.New("AesDecrypt ciphertext too short")
+	}
+
+	iv := text[:aes.BlockSize]
+	text = text[aes.BlockSize:]
+	cfb := cipher.NewCFBDecrypter(block, iv)
+	cfb.XORKeyStream(text, text)
+
+	data, err := base64.StdEncoding.DecodeString(string(text))
+	if err != nil {
+		return nil, fmt.Errorf("AesDecrypt invalid decrypted base64 string: %w", err)
+	}
+
+	return data, nil
+}

+ 76 - 0
internal/crypto/aes_test.go

@@ -0,0 +1,76 @@
+package crypto
+
+import (
+	"github.com/0xJacky/Nginx-UI/settings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func EncryptDecryptRoundTrip(text string) bool {
+	encrypted, err := AesEncrypt([]byte(text))
+	if err != nil {
+		return false
+	}
+
+	decrypted, err := AesDecrypt(encrypted)
+	if err != nil {
+		return false
+	}
+
+	return text == string(decrypted)
+}
+
+func EncryptsNonEmptyStringWithoutError(text string) bool {
+	_, err := AesEncrypt([]byte(text))
+	return err == nil
+}
+
+func DecryptsToOriginalTextAfterEncryption(text string) bool {
+	encrypted, _ := AesEncrypt([]byte(text))
+	decrypted, err := AesDecrypt(encrypted)
+	if err != nil {
+		return false
+	}
+
+	return text == string(decrypted)
+}
+
+func FailsToDecryptWithModifiedCiphertext(text string) bool {
+	encrypted, _ := AesEncrypt([]byte(text))
+	// Modify the ciphertext
+	encrypted[0] ^= 0xff
+	_, err := AesDecrypt(encrypted)
+	return err != nil
+}
+
+func FailsToDecryptShortCiphertext() bool {
+	_, err := AesDecrypt([]byte("short"))
+	return err != nil
+}
+
+func TestAesEncryptionDecryption(t *testing.T) {
+	settings.CryptoSettings.Secret = "test"
+	assert.True(t, EncryptDecryptRoundTrip("Hello, world!"), "should encrypt and decrypt to the original text")
+	assert.True(t, EncryptsNonEmptyStringWithoutError("Test String"), "should encrypt a non-empty string without error")
+	assert.True(t, DecryptsToOriginalTextAfterEncryption("Another Test String"), "should decrypt to the original text after encryption")
+	assert.True(t, FailsToDecryptWithModifiedCiphertext("Sensitive Data"), "should fail to decrypt with modified ciphertext")
+	assert.True(t, FailsToDecryptShortCiphertext(), "should fail to decrypt short ciphertext")
+}
+
+func TestAesEncrypt_WithEmptyString_ReturnsError(t *testing.T) {
+	settings.CryptoSettings.Secret = "test"
+	_, err := AesEncrypt([]byte(""))
+	require.Error(t, err, "encrypting an empty string should return an error")
+}
+
+func TestAesDecrypt_WithInvalidBase64_ReturnsError(t *testing.T) {
+	settings.CryptoSettings.Secret = "test"
+	// Assuming the function is modified to handle this case explicitly
+	encrypted, _ := AesEncrypt([]byte("valid text"))
+	// Invalidate the base64 encoding
+	encrypted[len(encrypted)-1] = '!'
+	_, err := AesDecrypt(encrypted)
+	require.Error(t, err, "decrypting an invalid base64 string should return an error")
+}

+ 27 - 21
internal/kernal/boot.go

@@ -1,20 +1,21 @@
 package kernal
 
 import (
+	"crypto/rand"
+	"encoding/hex"
 	"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/cluster"
+	"github.com/0xJacky/Nginx-UI/internal/cron"
 	"github.com/0xJacky/Nginx-UI/internal/logger"
-	"github.com/0xJacky/Nginx-UI/internal/logrotate"
 	"github.com/0xJacky/Nginx-UI/internal/validation"
 	"github.com/0xJacky/Nginx-UI/model"
 	"github.com/0xJacky/Nginx-UI/query"
 	"github.com/0xJacky/Nginx-UI/settings"
-	"github.com/go-co-op/gocron"
 	"github.com/google/uuid"
 	"mime"
 	"runtime"
-	"time"
 )
 
 func Boot() {
@@ -24,7 +25,9 @@ func Boot() {
 		InitJsExtensionType,
 		InitDatabase,
 		InitNodeSecret,
+		InitCryptoSecret,
 		validation.Init,
+		cache.Init,
 	}
 
 	syncs := []func(){
@@ -44,7 +47,7 @@ func InitAfterDatabase() {
 	syncs := []func(){
 		registerPredefinedUser,
 		cert.InitRegister,
-		InitCronJobs,
+		cron.InitCronJobs,
 		cluster.RegisterPredefinedNodes,
 		analytic.RetrieveNodesStatus,
 	}
@@ -83,31 +86,34 @@ func InitNodeSecret() {
 
 		err := settings.Save()
 		if err != nil {
-			logger.Error("Error save settings")
+			logger.Error("Error save settings", err)
 		}
 		logger.Warn("Generated NodeSecret: ", settings.ServerSettings.NodeSecret)
 	}
 }
 
-func InitJsExtensionType() {
-	// Hack: fix wrong Content Type of .js file on some OS platforms
-	// See https://github.com/golang/go/issues/32350
-	_ = mime.AddExtensionType(".js", "text/javascript; charset=utf-8")
-}
-
-func InitCronJobs() {
-	s := gocron.NewScheduler(time.UTC)
-	job, err := s.Every(6).Hours().SingletonMode().Do(cert.AutoCert)
+func InitCryptoSecret() {
+	if "" == settings.CryptoSettings.Secret {
+		logger.Warn("Secret is empty, generating...")
 
-	if err != nil {
-		logger.Fatalf("AutoCert Job: %v, Err: %v\n", job, err)
-	}
+		key := make([]byte, 32)
+		if _, err := rand.Read(key); err != nil {
+			logger.Error("Generate Secret failed: ", err)
+			return
+		}
 
-	job, err = s.Every(settings.LogrotateSettings.Interval).Minute().SingletonMode().Do(logrotate.Exec)
+		settings.CryptoSettings.Secret = hex.EncodeToString(key)
 
-	if err != nil {
-		logger.Fatalf("LogRotate Job: %v, Err: %v\n", job, err)
+		err := settings.Save()
+		if err != nil {
+			logger.Error("Error save settings", err)
+		}
+		logger.Warn("Secret Generated")
 	}
+}
 
-	s.StartAsync()
+func InitJsExtensionType() {
+	// Hack: fix wrong Content Type of .js file on some OS platforms
+	// See https://github.com/golang/go/issues/32350
+	_ = mime.AddExtensionType(".js", "text/javascript; charset=utf-8")
 }

+ 63 - 0
internal/user/otp.go

@@ -0,0 +1,63 @@
+package user
+
+import (
+	"bytes"
+	"crypto/sha1"
+	"encoding/hex"
+	"fmt"
+	"github.com/0xJacky/Nginx-UI/internal/cache"
+	"github.com/0xJacky/Nginx-UI/internal/crypto"
+	"github.com/0xJacky/Nginx-UI/model"
+	"github.com/google/uuid"
+	"github.com/pkg/errors"
+	"github.com/pquerna/otp/totp"
+	"time"
+)
+
+var (
+	ErrOTPCode      = errors.New("invalid otp code")
+	ErrRecoveryCode = errors.New("invalid recovery code")
+)
+
+func VerifyOTP(user *model.Auth, otp, recoveryCode string) (err error) {
+	if otp != "" {
+		decrypted, err := crypto.AesDecrypt(user.OTPSecret)
+		if err != nil {
+			return err
+		}
+
+		if ok := totp.Validate(otp, string(decrypted)); !ok {
+			return ErrOTPCode
+		}
+	} else {
+		recoverCode, err := hex.DecodeString(recoveryCode)
+		if err != nil {
+			return err
+		}
+		k := sha1.Sum(user.OTPSecret)
+		if !bytes.Equal(k[:], recoverCode) {
+			return ErrRecoveryCode
+		}
+	}
+	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
+}

+ 94 - 16
internal/user/user.go

@@ -1,53 +1,84 @@
 package user
 
 import (
+	"github.com/0xJacky/Nginx-UI/internal/logger"
 	"github.com/0xJacky/Nginx-UI/model"
+	"github.com/0xJacky/Nginx-UI/query"
 	"github.com/0xJacky/Nginx-UI/settings"
 	"github.com/dgrijalva/jwt-go"
+	"github.com/pkg/errors"
+	"strings"
 	"time"
 )
 
+const ExpiredTime = 24 * time.Hour
+
 type JWTClaims struct {
-	Name string `json:"name"`
+	Name   string `json:"name"`
+	UserID int    `json:"user_id"`
 	jwt.StandardClaims
 }
 
-func GetUser(name string) (user model.Auth, err error) {
+func BuildCacheTokenKey(token string) string {
+	var sb strings.Builder
+	sb.WriteString("token:")
+	sb.WriteString(token)
+	return sb.String()
+}
+
+func GetUser(name string) (user *model.Auth, err error) {
 	db := model.UseDB()
-	err = db.Where("name", name).First(&user).Error
+	user = &model.Auth{}
+	err = db.Where("name", name).First(user).Error
 	if err != nil {
 		return
 	}
 	return
 }
 
-func DeleteToken(token string) error {
-	db := model.UseDB()
-	return db.Where("token", token).Delete(&model.AuthToken{}).Error
+func DeleteToken(token string) {
+	q := query.AuthToken
+	_, _ = q.Where(q.Token.Eq(token)).Delete()
 }
 
-func CheckToken(token string) int64 {
-	db := model.UseDB()
-	return db.Where("token", token).Find(&model.AuthToken{}).RowsAffected
+func GetTokenUser(token string) (*model.Auth, bool) {
+	q := query.AuthToken
+	authToken, err := q.Where(q.Token.Eq(token)).First()
+	if err != nil {
+		return nil, false
+	}
+
+	if authToken.ExpiredAt < time.Now().Unix() {
+		DeleteToken(token)
+		return nil, false
+	}
+
+	u := query.Auth
+	user, err := u.FirstByID(authToken.UserID)
+	return user, err == nil
 }
 
-func GenerateJWT(name string) (string, error) {
+func GenerateJWT(user *model.Auth) (string, error) {
 	claims := JWTClaims{
-		Name: name,
+		Name:   user.Name,
+		UserID: user.ID,
 		StandardClaims: jwt.StandardClaims{
-			ExpiresAt: time.Now().Add(24 * time.Hour).Unix(),
+			ExpiresAt: time.Now().Add(ExpiredTime).Unix(),
 		},
 	}
+
 	unsignedToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
 	signedToken, err := unsignedToken.SignedString([]byte(settings.ServerSettings.JwtSecret))
 	if err != nil {
 		return "", err
 	}
 
-	db := model.UseDB()
-	err = db.Create(&model.AuthToken{
-		Token: signedToken,
-	}).Error
+	q := query.AuthToken
+	err = q.Create(&model.AuthToken{
+		UserID:    user.ID,
+		Token:     signedToken,
+		ExpiredAt: time.Now().Add(ExpiredTime).Unix(),
+	})
 
 	if err != nil {
 		return "", err
@@ -55,3 +86,50 @@ func GenerateJWT(name string) (string, error) {
 
 	return signedToken, err
 }
+
+func ValidateJWT(token string) (claims *JWTClaims, err error) {
+	if token == "" {
+		err = errors.New("token is empty")
+		return
+	}
+	unsignedToken, err := jwt.ParseWithClaims(
+		token,
+		&JWTClaims{},
+		func(token *jwt.Token) (interface{}, error) {
+			return []byte(settings.ServerSettings.JwtSecret), nil
+		},
+	)
+	if err != nil {
+		err = errors.New("parse with claims error")
+		return
+	}
+	claims, ok := unsignedToken.Claims.(*JWTClaims)
+	if !ok {
+		err = errors.New("convert to jwt claims error")
+		return
+	}
+	if claims.ExpiresAt < time.Now().UTC().Unix() {
+		err = errors.New("jwt is expired")
+	}
+	return
+}
+
+func CurrentUser(token string) (u *model.Auth, err error) {
+	// validate token
+	var claims *JWTClaims
+	claims, err = ValidateJWT(token)
+	if err != nil {
+		return
+	}
+
+	// get user by id
+	user := query.Auth
+	u, err = user.FirstByID(claims.UserID)
+	if err != nil {
+		return
+	}
+
+	logger.Info("[Current User]", u.Name)
+
+	return
+}

+ 12 - 5
model/auth.go

@@ -1,13 +1,20 @@
 package model
 
 type Auth struct {
-    Model
+	Model
 
-    Name     string `json:"name"`
-    Password string `json:"-"`
-    Status   bool   `json:"status" gorm:"default:1"`
+	Name      string `json:"name"`
+	Password  string `json:"-"`
+	Status    bool   `json:"status" gorm:"default:1"`
+	OTPSecret []byte `json:"-" gorm:"type:blob"`
 }
 
 type AuthToken struct {
-    Token string `json:"token"`
+	UserID    int    `json:"user_id"`
+	Token     string `json:"token"`
+	ExpiredAt int64  `json:"expired_at" gorm:"default:0"`
+}
+
+func (u *Auth) EnabledOTP() bool {
+	return len(u.OTPSecret) != 0
 }

+ 11 - 3
query/auth_tokens.gen.go

@@ -28,7 +28,9 @@ func newAuthToken(db *gorm.DB, opts ...gen.DOOption) authToken {
 
 	tableName := _authToken.authTokenDo.TableName()
 	_authToken.ALL = field.NewAsterisk(tableName)
+	_authToken.UserID = field.NewInt(tableName, "user_id")
 	_authToken.Token = field.NewString(tableName, "token")
+	_authToken.ExpiredAt = field.NewInt64(tableName, "expired_at")
 
 	_authToken.fillFieldMap()
 
@@ -38,8 +40,10 @@ func newAuthToken(db *gorm.DB, opts ...gen.DOOption) authToken {
 type authToken struct {
 	authTokenDo
 
-	ALL   field.Asterisk
-	Token field.String
+	ALL       field.Asterisk
+	UserID    field.Int
+	Token     field.String
+	ExpiredAt field.Int64
 
 	fieldMap map[string]field.Expr
 }
@@ -56,7 +60,9 @@ func (a authToken) As(alias string) *authToken {
 
 func (a *authToken) updateTableName(table string) *authToken {
 	a.ALL = field.NewAsterisk(table)
+	a.UserID = field.NewInt(table, "user_id")
 	a.Token = field.NewString(table, "token")
+	a.ExpiredAt = field.NewInt64(table, "expired_at")
 
 	a.fillFieldMap()
 
@@ -73,8 +79,10 @@ func (a *authToken) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
 }
 
 func (a *authToken) fillFieldMap() {
-	a.fieldMap = make(map[string]field.Expr, 1)
+	a.fieldMap = make(map[string]field.Expr, 3)
+	a.fieldMap["user_id"] = a.UserID
 	a.fieldMap["token"] = a.Token
+	a.fieldMap["expired_at"] = a.ExpiredAt
 }
 
 func (a authToken) clone(db *gorm.DB) authToken {

+ 5 - 1
query/auths.gen.go

@@ -35,6 +35,7 @@ func newAuth(db *gorm.DB, opts ...gen.DOOption) auth {
 	_auth.Name = field.NewString(tableName, "name")
 	_auth.Password = field.NewString(tableName, "password")
 	_auth.Status = field.NewBool(tableName, "status")
+	_auth.OTPSecret = field.NewBytes(tableName, "otp_secret")
 
 	_auth.fillFieldMap()
 
@@ -52,6 +53,7 @@ type auth struct {
 	Name      field.String
 	Password  field.String
 	Status    field.Bool
+	OTPSecret field.Bytes
 
 	fieldMap map[string]field.Expr
 }
@@ -75,6 +77,7 @@ func (a *auth) updateTableName(table string) *auth {
 	a.Name = field.NewString(table, "name")
 	a.Password = field.NewString(table, "password")
 	a.Status = field.NewBool(table, "status")
+	a.OTPSecret = field.NewBytes(table, "otp_secret")
 
 	a.fillFieldMap()
 
@@ -91,7 +94,7 @@ func (a *auth) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
 }
 
 func (a *auth) fillFieldMap() {
-	a.fieldMap = make(map[string]field.Expr, 7)
+	a.fieldMap = make(map[string]field.Expr, 8)
 	a.fieldMap["id"] = a.ID
 	a.fieldMap["created_at"] = a.CreatedAt
 	a.fieldMap["updated_at"] = a.UpdatedAt
@@ -99,6 +102,7 @@ func (a *auth) fillFieldMap() {
 	a.fieldMap["name"] = a.Name
 	a.fieldMap["password"] = a.Password
 	a.fieldMap["status"] = a.Status
+	a.fieldMap["otp_secret"] = a.OTPSecret
 }
 
 func (a auth) clone(db *gorm.DB) auth {

+ 40 - 1
router/middleware.go

@@ -5,6 +5,7 @@ import (
 	"github.com/0xJacky/Nginx-UI/app"
 	"github.com/0xJacky/Nginx-UI/internal/logger"
 	"github.com/0xJacky/Nginx-UI/internal/user"
+	"github.com/0xJacky/Nginx-UI/model"
 	"github.com/0xJacky/Nginx-UI/settings"
 	"github.com/gin-contrib/static"
 	"github.com/gin-gonic/gin"
@@ -58,11 +59,14 @@ func authRequired() gin.HandlerFunc {
 			}
 		}
 
-		if user.CheckToken(token) < 1 {
+		u, ok := user.GetTokenUser(token)
+		if !ok {
 			abortWithAuthFailure()
 			return
 		}
 
+		c.Set("user", u)
+
 		if nodeID := c.GetHeader("X-Node-ID"); nodeID != "" {
 			c.Set("ProxyNodeID", nodeID)
 		}
@@ -71,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 {
 	http.FileSystem
 }

+ 5 - 1
router/routers.go

@@ -46,6 +46,7 @@ func InitRouter() *gin.Engine {
 		// Authorization required not websocket request
 		g := root.Group("/", authRequired(), proxy())
 		{
+			user.InitUserRouter(g)
 			analytic.InitRouter(g)
 			user.InitManageUserRouter(g)
 			nginx.InitRouter(g)
@@ -68,7 +69,10 @@ func InitRouter() *gin.Engine {
 		{
 			analytic.InitWebSocketRouter(w)
 			certificate.InitCertificateWebSocketRouter(w)
-			terminal.InitRouter(w)
+			o := w.Group("", required2FA())
+			{
+				terminal.InitRouter(o)
+			}
 			nginx.InitNginxLogRouter(w)
 			upstream.InitRouter(w)
 			system.InitWebSocketRouter(w)

+ 1 - 0
settings/cluster_test.go

@@ -11,5 +11,6 @@ func TestCluster(t *testing.T) {
 	assert.Equal(t, []string{
 		"http://10.0.0.1:9000?name=node1&node_secret=my-node-secret&enabled=true",
 		"http://10.0.0.2:9000?name=node2&node_secret=my-node-secret&enabled=true",
+		"http://10.0.0.3?name=node3&node_secret=my-node-secret&enabled=true",
 	}, ClusterSettings.Node)
 }

+ 14 - 0
settings/crypto.go

@@ -0,0 +1,14 @@
+package settings
+
+import "crypto/md5"
+
+type Crypto struct {
+	Secret string
+}
+
+var CryptoSettings = Crypto{}
+
+func (c *Crypto) GetSecretMd5() []byte {
+	k := md5.Sum([]byte(c.Secret))
+	return k[:]
+}

+ 48 - 0
settings/crypto_test.go

@@ -0,0 +1,48 @@
+package settings
+
+import (
+	"crypto/md5"
+	"encoding/hex"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestGetSecretMd5_WithNonEmptySecret_ReturnsExpectedMd5Hash(t *testing.T) {
+	// Setup
+	CryptoSettings.Secret = "testSecret"
+	expectedMd5 := md5.Sum([]byte("testSecret"))
+	expectedMd5String := hex.EncodeToString(expectedMd5[:])
+
+	// Execute
+	resultMd5 := CryptoSettings.GetSecretMd5()
+	resultMd5String := hex.EncodeToString(resultMd5[:])
+
+	// Verify
+	assert.Equal(t, expectedMd5String, resultMd5String, "MD5 hash should match for non-empty secret")
+}
+
+func TestGetSecretMd5_WithEmptySecret_ReturnsMd5OfEmptyString(t *testing.T) {
+	// Setup
+	CryptoSettings.Secret = ""
+	expectedMd5 := md5.Sum([]byte(""))
+	expectedMd5String := hex.EncodeToString(expectedMd5[:])
+
+	// Execute
+	resultMd5 := CryptoSettings.GetSecretMd5()
+	resultMd5String := hex.EncodeToString(resultMd5[:])
+
+	// Verify
+	assert.Equal(t, expectedMd5String, resultMd5String, "MD5 hash of an empty string should be returned for empty secret")
+}
+
+func TestGetSecretMd5_WithDifferentSecrets_ReturnsDifferentMd5Hashes(t *testing.T) {
+	// Setup
+	CryptoSettings.Secret = "secret1"
+	firstMd5 := CryptoSettings.GetSecretMd5()
+	CryptoSettings.Secret = "secret2"
+	secondMd5 := CryptoSettings.GetSecretMd5()
+
+	// Verify
+	assert.NotEqual(t, firstMd5, secondMd5, "Different secrets should produce different MD5 hashes")
+}

+ 2 - 0
settings/settings.go

@@ -28,6 +28,7 @@ var sections = map[string]interface{}{
 	"logrotate": &LogrotateSettings,
 	"cluster":   &ClusterSettings,
 	"auth":      &AuthSettings,
+	"crypto":    &CryptoSettings,
 }
 
 func init() {
@@ -64,6 +65,7 @@ func Setup() {
 	parseEnv(&CasdoorSettings, "CASDOOR_")
 	parseEnv(&LogrotateSettings, "LOGROTATE_")
 	parseEnv(&AuthSettings, "AUTH_")
+	parseEnv(&CryptoSettings, "CRYPTO_")
 
 	// if in official docker, set the restart cmd of nginx to "nginx -s stop",
 	// then the supervisor of s6-overlay will start the nginx again.