Browse Source

feat: login 2fa

Jacky 9 months ago
parent
commit
5abd9b75bb

+ 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 len(u.OTPSecret) > 0 {
+		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

+ 163 - 0
api/user/otp.go

@@ -0,0 +1,163 @@
+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/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) {
+	user := api.CurrentUser(c)
+
+	issuer := fmt.Sprintf("Nginx UI %s", settings.ServerSettings.Name)
+	issuer = strings.TrimSpace(issuer)
+
+	otpOpts := totp.GenerateOpts{
+		Issuer:      issuer,
+		AccountName: user.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) {
+	user := api.CurrentUser(c)
+	if len(user.OTPSecret) > 0 {
+		c.JSON(http.StatusBadRequest, gin.H{
+			"message": "User already enrolled",
+		})
+		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(user.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
+	}
+	user := api.CurrentUser(c)
+	k := sha1.Sum(user.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(user.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,
+	})
+}

+ 17 - 10
api/user/router.go

@@ -2,18 +2,25 @@ 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)
 }

+ 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

+ 2 - 0
app/components.d.ts

@@ -78,6 +78,8 @@ 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']
+    OTPInput: typeof import('./src/components/OTPInput.vue')['default']
+    OTPInputOTPInput: typeof import('./src/components/OTPInput/OTPInput.vue')['default']
     PageHeaderPageHeader: typeof import('./src/components/PageHeader/PageHeader.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']

+ 1 - 0
app/package.json

@@ -38,6 +38,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": {

+ 12 - 0
app/pnpm-lock.yaml

@@ -89,6 +89,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))
@@ -2969,6 +2972,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:
@@ -6175,6 +6183,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) {

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

@@ -0,0 +1,23 @@
+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 })
+  },
+}
+
+export default otp

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

+ 84 - 25
app/src/views/other/Login.vue

@@ -1,13 +1,14 @@
 <script setup lang="ts">
 import { LockOutlined, UserOutlined } from '@ant-design/icons-vue'
 import { Form, message } from 'ant-design-vue'
+import OTPInput from '@/components/OTPInput/OTPInput.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'
 
 const thisYear = new Date().getFullYear()
 
@@ -20,6 +21,11 @@ install.get_lock().then(async (r: { lock: boolean }) => {
 })
 
 const loading = ref(false)
+const enabled2FA = ref(false)
+const refOTP = ref()
+const passcode = ref('')
+const useRecoveryCode = ref(false)
+const recoveryCode = ref('')
 
 const modelRef = reactive({
   username: '',
@@ -42,17 +48,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 +78,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 +135,10 @@ if (route.query?.code !== undefined && route.query?.state !== undefined) {
   loading.value = false
 }
 
+function clickUseRecoveryCode() {
+  passcode.value = ''
+  useRecoveryCode.value = true
+}
 </script>
 
 <template>
@@ -128,27 +150,64 @@ 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 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>
+              <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 class="flex justify-center">
+                  <a @click="clickUseRecoveryCode">{{ $gettext('Use recovery code') }}</a>
+                </div>
+              </div>
+
+              <div
+                v-else
+                class="mt-2"
               >
-                <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>
+                <p>{{ $gettext('Input the recovery code:') }}</p>
+                <AInputGroup compact>
+                  <AInput
+                    v-model:value="recoveryCode"
+                    style="width: calc(100% - 92px)"
+                  />
+                  <AButton
+                    type="primary"
+                    @click="onSubmit"
+                  >
+                    {{ $gettext('Recovery') }}
+                  </AButton>
+                </AInputGroup>
+              </div>
+            </div>
+
+            <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
 
@@ -105,6 +106,8 @@ function removeBannedIP(ip: string) {
           </template>
         </ATable>
       </div>
+
+      <TOTP />
     </div>
   </div>
 </template>

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

+ 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=

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

+ 25 - 21
internal/kernal/boot.go

@@ -1,20 +1,20 @@
 package kernal
 
 import (
+	"crypto/rand"
+	"encoding/hex"
 	"github.com/0xJacky/Nginx-UI/internal/analytic"
 	"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,6 +24,7 @@ func Boot() {
 		InitJsExtensionType,
 		InitDatabase,
 		InitNodeSecret,
+		InitCryptoSecret,
 		validation.Init,
 	}
 
@@ -44,7 +45,7 @@ func InitAfterDatabase() {
 	syncs := []func(){
 		registerPredefinedUser,
 		cert.InitRegister,
-		InitCronJobs,
+		cron.InitCronJobs,
 		cluster.RegisterPredefinedNodes,
 		analytic.RetrieveNodesStatus,
 	}
@@ -83,31 +84,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")
 }

+ 39 - 0
internal/user/otp.go

@@ -0,0 +1,39 @@
+package user
+
+import (
+	"bytes"
+	"crypto/sha1"
+	"encoding/hex"
+	"github.com/0xJacky/Nginx-UI/internal/crypto"
+	"github.com/0xJacky/Nginx-UI/model"
+	"github.com/pkg/errors"
+	"github.com/pquerna/otp/totp"
+)
+
+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
+}

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

+ 8 - 5
model/auth.go

@@ -1,13 +1,16 @@
 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"`
 }

+ 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 {

+ 4 - 1
router/middleware.go

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

+ 1 - 0
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)

+ 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.