Browse Source

feat(auth): implement short token for user authentication and update related login responses

Jacky 1 day ago
parent
commit
a3f8f90668

+ 13 - 26
api/user/auth.go

@@ -1,7 +1,6 @@
 package user
 
 import (
-	"errors"
 	"math/rand/v2"
 	"net/http"
 	"sync"
@@ -32,10 +31,10 @@ const (
 )
 
 type LoginResponse struct {
-	Message         string `json:"message"`
-	Error           string `json:"error,omitempty"`
-	Code            int    `json:"code"`
-	Token           string `json:"token,omitempty"`
+	Message string `json:"message"`
+	Error   string `json:"error,omitempty"`
+	Code    int    `json:"code"`
+	*user.AccessTokenPayload
 	SecureSessionID string `json:"secure_session_id,omitempty"`
 }
 
@@ -67,17 +66,10 @@ func Login(c *gin.Context) {
 
 	u, err := user.Login(json.Name, json.Password)
 	if err != nil {
+		user.BanIP(clientIP)
 		random := time.Duration(rand.Int() % 10)
 		time.Sleep(random * time.Second)
-		switch {
-		case errors.Is(err, user.ErrPasswordIncorrect):
-			c.JSON(http.StatusForbidden, user.ErrPasswordIncorrect)
-		case errors.Is(err, user.ErrUserBanned):
-			c.JSON(http.StatusForbidden, user.ErrUserBanned)
-		default:
-			cosy.ErrHandler(c, err)
-		}
-		user.BanIP(clientIP)
+		cosy.ErrHandler(c, err)
 		return
 	}
 
@@ -95,10 +87,7 @@ func Login(c *gin.Context) {
 		}
 
 		if err = user.VerifyOTP(u, json.OTP, json.RecoveryCode); err != nil {
-			c.JSON(http.StatusForbidden, LoginResponse{
-				Message: "Invalid 2FA or recovery code",
-				Code:    Error2FACode,
-			})
+			cosy.ErrHandler(c, err)
 			user.BanIP(clientIP)
 			return
 		}
@@ -110,19 +99,17 @@ func Login(c *gin.Context) {
 	_, _ = b.Where(b.IP.Eq(clientIP)).Delete()
 
 	logger.Info("[User Login]", u.Name)
-	token, err := user.GenerateJWT(u)
+	accessToken, err := user.GenerateJWT(u)
 	if err != nil {
-		c.JSON(http.StatusInternalServerError, LoginResponse{
-			Message: err.Error(),
-		})
+		cosy.ErrHandler(c, err)
 		return
 	}
 
 	c.JSON(http.StatusOK, LoginResponse{
-		Code:            LoginSuccess,
-		Message:         "ok",
-		Token:           token,
-		SecureSessionID: secureSessionID,
+		Code:               LoginSuccess,
+		Message:            "ok",
+		AccessTokenPayload: accessToken,
+		SecureSessionID:    secureSessionID,
 	})
 }
 

+ 2 - 2
api/user/casdoor.go

@@ -81,8 +81,8 @@ func CasdoorCallback(c *gin.Context) {
 	}
 
 	c.JSON(http.StatusOK, LoginResponse{
-		Message: "ok",
-		Token:   userToken,
+		Message:            "ok",
+		AccessTokenPayload: userToken,
 	})
 }
 

+ 2 - 2
api/user/passkey.go

@@ -157,8 +157,8 @@ func FinishPasskeyLogin(c *gin.Context) {
 	c.JSON(http.StatusOK, LoginResponse{
 		Code:            LoginSuccess,
 		Message:         "ok",
-		Token:           token,
-		SecureSessionID: secureSessionID,
+		AccessTokenPayload: token,
+		SecureSessionID:    secureSessionID,
 	})
 }
 

+ 3 - 0
app/components.d.ts

@@ -21,6 +21,7 @@ declare module 'vue' {
     ACol: typeof import('ant-design-vue/es')['Col']
     ACollapse: typeof import('ant-design-vue/es')['Collapse']
     ACollapsePanel: typeof import('ant-design-vue/es')['CollapsePanel']
+    AComment: typeof import('ant-design-vue/es')['Comment']
     AConfigProvider: typeof import('ant-design-vue/es')['ConfigProvider']
     ADivider: typeof import('ant-design-vue/es')['Divider']
     ADrawer: typeof import('ant-design-vue/es')['Drawer']
@@ -46,6 +47,7 @@ declare module 'vue' {
     APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
     APopover: typeof import('ant-design-vue/es')['Popover']
     AProgress: typeof import('ant-design-vue/es')['Progress']
+    AQrcode: typeof import('ant-design-vue/es')['QRCode']
     AResult: typeof import('ant-design-vue/es')['Result']
     ARow: typeof import('ant-design-vue/es')['Row']
     ASelect: typeof import('ant-design-vue/es')['Select']
@@ -63,6 +65,7 @@ declare module 'vue' {
     ATag: typeof import('ant-design-vue/es')['Tag']
     ATextarea: typeof import('ant-design-vue/es')['Textarea']
     ATooltip: typeof import('ant-design-vue/es')['Tooltip']
+    AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger']
     AutoCertFormAutoCertForm: typeof import('./src/components/AutoCertForm/AutoCertForm.vue')['default']
     AutoCertFormDNSChallenge: typeof import('./src/components/AutoCertForm/DNSChallenge.vue')['default']
     BaseEditorBaseEditor: typeof import('./src/components/BaseEditor/BaseEditor.vue')['default']

+ 2 - 1
app/src/api/auth.ts

@@ -7,6 +7,7 @@ const { login, logout } = useUserStore()
 export interface AuthResponse {
   message: string
   token: string
+  short_token: string
   code: number
   error: string
   secure_session_id: string
@@ -27,7 +28,7 @@ const auth = {
       state,
     })
       .then((r: AuthResponse) => {
-        login(r.token)
+        login(r.token, r.short_token)
       })
   },
   async logout() {

+ 2 - 0
app/src/constants/errors/user.ts

@@ -10,4 +10,6 @@ export default {
   50003: () => $gettext('Cannot remove initial user'),
   50004: () => $gettext('Cannot change initial user password in demo mode'),
   40401: () => $gettext('Session not found'),
+  40402: () => $gettext('Token is empty'),
+  50005: () => $gettext('Invalid claims type'),
 }

+ 1 - 0
app/src/lib/helper/index.ts

@@ -41,6 +41,7 @@ function downloadCsv(header: any, data: any[], fileName: string) {
 
 function urlJoin(...args: string[]) {
   return args
+    .filter(arg => arg)
     .join('/')
     .replace(/\/+/g, '/')
     .replace(/^(.+):\//, '$1://')

+ 8 - 5
app/src/lib/websocket/index.ts

@@ -6,27 +6,30 @@ import { useSettingsStore, useUserStore } from '@/pinia'
 /**
  * Build WebSocket URL based on environment
  */
-function buildWebSocketUrl(url: string, token: string, nodeId?: number): string {
+function buildWebSocketUrl(url: string, token: string, shortToken: string, nodeId?: number): string {
   const node_id = nodeId && nodeId > 0 ? `&x_node_id=${nodeId}` : ''
 
+  // Use shortToken if available (without base64 encoding), otherwise use regular token (with base64 encoding)
+  const authParam = shortToken ? `token=${shortToken}` : `token=${btoa(token)}`
+
   // In development mode, connect directly to backend server
   if (import.meta.env.DEV) {
     const proxyTarget = import.meta.env.VITE_PROXY_TARGET || 'http://localhost:9000'
     const wsTarget = proxyTarget.replace(/^https?:/, location.protocol === 'https:' ? 'wss:' : 'ws:')
-    return urlJoin(wsTarget, url, `?token=${btoa(token)}`, node_id)
+    return urlJoin(wsTarget, url, `?${authParam}`, node_id)
   }
 
   // In production mode, use current host
   const protocol = location.protocol === 'https:' ? 'wss://' : 'ws://'
-  return urlJoin(protocol + window.location.host, window.location.pathname, url, `?token=${btoa(token)}`, node_id)
+  return urlJoin(protocol + window.location.host, window.location.pathname, url, `?${authParam}`, node_id)
 }
 
 function ws(url: string, reconnect: boolean = true): ReconnectingWebSocket | WebSocket {
   const user = useUserStore()
   const settings = useSettingsStore()
-  const { token } = storeToRefs(user)
+  const { token, shortToken } = storeToRefs(user)
 
-  const _url = buildWebSocketUrl(url, token.value, settings.environment.id)
+  const _url = buildWebSocketUrl(url, token.value, shortToken.value, settings.environment.id)
 
   if (reconnect)
     return new ReconnectingWebSocket(_url, undefined, { maxRetries: 10 })

+ 15 - 3
app/src/pinia/moudule/user.ts

@@ -8,11 +8,16 @@ export const useUserStore = defineStore('user', () => {
   const cookies = useCookies(['nginx-ui'])
 
   const token = ref('')
+  const shortToken = ref('')
 
   watch(token, v => {
     cookies.set('token', v, { maxAge: 86400 })
   })
 
+  watch(shortToken, v => {
+    cookies.set('short_token', v, { maxAge: 86400 })
+  })
+
   const secureSessionId = ref('')
 
   watch(secureSessionId, v => {
@@ -22,6 +27,8 @@ export const useUserStore = defineStore('user', () => {
   function handleCookieChange({ name, value }: CookieChangeOptions) {
     if (name === 'token')
       token.value = value
+    else if (name === 'short_token')
+      shortToken.value = value
     else if (name === 'secure_session_id')
       secureSessionId.value = value
   }
@@ -35,17 +42,21 @@ export const useUserStore = defineStore('user', () => {
   const isLogin = computed(() => !!token.value)
   const passkeyLoginAvailable = computed(() => !!passkeyRawId.value)
 
-  function passkeyLogin(rawId: string, tokenValue: string) {
+  function passkeyLogin(rawId: string, tokenValue: string, shortTokenValue?: string) {
     passkeyRawId.value = rawId
-    login(tokenValue)
+    login(tokenValue, shortTokenValue)
   }
 
-  function login(tokenValue: string) {
+  function login(tokenValue: string, shortTokenValue?: string) {
     token.value = tokenValue
+    if (shortTokenValue) {
+      shortToken.value = shortTokenValue
+    }
   }
 
   function logout() {
     token.value = ''
+    shortToken.value = ''
     passkeyRawId.value = ''
     secureSessionId.value = ''
     unreadCount.value = 0
@@ -100,6 +111,7 @@ export const useUserStore = defineStore('user', () => {
 
   return {
     token,
+    shortToken,
     unreadCount,
     secureSessionId,
     passkeyRawId,

+ 1 - 1
app/src/version.json

@@ -1 +1 @@
-{"version":"2.1.10","build_id":3,"total_build":445}
+{"version":"2.1.10","build_id":5,"total_build":447}

+ 6 - 2
app/src/views/other/Login.vue

@@ -57,6 +57,7 @@ const { secureSessionId } = storeToRefs(userStore)
 
 interface LoginSuccessOptions {
   token?: string
+  shortToken?: string
   secureSessionId?: string
   loginType?: 'normal' | 'passkey'
   passkeyRawId?: string
@@ -66,6 +67,7 @@ interface LoginSuccessOptions {
 async function handleLoginSuccess(options: LoginSuccessOptions = {}) {
   const {
     token,
+    shortToken,
     secureSessionId: sessionId,
     loginType = 'normal',
     passkeyRawId,
@@ -78,10 +80,10 @@ async function handleLoginSuccess(options: LoginSuccessOptions = {}) {
 
   // Handle different login types
   if (loginType === 'passkey' && passkeyRawId && token) {
-    passkeyLogin(passkeyRawId, token)
+    passkeyLogin(passkeyRawId, token, shortToken)
   }
   else if (token) {
-    login(token)
+    login(token, shortToken)
   }
 
   await nextTick()
@@ -112,6 +114,7 @@ function onSubmit() {
         case 200:
           await handleLoginSuccess({
             token: r.token,
+            shortToken: r.short_token,
             secureSessionId: r.secure_session_id,
           })
           break
@@ -191,6 +194,7 @@ async function handlePasskeyLogin() {
   if (r.token) {
     await handleLoginSuccess({
       token: r.token,
+      shortToken: r.short_token,
       secureSessionId: r.secure_session_id,
       loginType: 'passkey',
       passkeyRawId: asseResp.rawId,

+ 27 - 6
internal/middleware/middleware.go

@@ -7,6 +7,7 @@ import (
 	"strings"
 
 	"github.com/0xJacky/Nginx-UI/internal/user"
+	"github.com/0xJacky/Nginx-UI/model"
 	"github.com/0xJacky/Nginx-UI/settings"
 	"github.com/gin-gonic/gin"
 	"github.com/uozi-tech/cosy/logger"
@@ -19,8 +20,13 @@ func getToken(c *gin.Context) (token string) {
 	}
 
 	if token = c.Query("token"); token != "" {
-		tokenBytes, _ := base64.StdEncoding.DecodeString(token)
-		return string(tokenBytes)
+		if len(token) > 16 {
+			// Long token (base64 encoded JWT)
+			tokenBytes, _ := base64.StdEncoding.DecodeString(token)
+			return string(tokenBytes)
+		}
+		// Short token (16 characters)
+		return token
 	}
 
 	if token, _ = c.Cookie("token"); token != "" {
@@ -75,10 +81,25 @@ func AuthRequired() gin.HandlerFunc {
 			return
 		}
 
-		u, ok := user.GetTokenUser(token)
-		if !ok {
-			abortWithAuthFailure()
-			return
+		var (
+			u  *model.User
+			ok bool
+		)
+
+		if len(token) <= 16 {
+			// Short token (16 characters)
+			u, ok = user.GetTokenUserByShortToken(token)
+			if !ok {
+				abortWithAuthFailure()
+				return
+			}
+		} else {
+			// Long JWT token
+			u, ok = user.GetTokenUser(token)
+			if !ok {
+				abortWithAuthFailure()
+				return
+			}
 		}
 
 		c.Set("user", u)

+ 2 - 0
internal/user/errors.go

@@ -15,4 +15,6 @@ var (
 	ErrCannotRemoveInitUser    = e.New(50003, "cannot remove initial user")
 	ErrChangeInitUserPwdInDemo = e.New(50004, "cannot change initial user password in demo mode")
 	ErrSessionNotFound         = e.New(40401, "session not found")
+	ErrTokenIsEmpty            = e.New(40402, "token is empty")
+	ErrInvalidClaimsType       = e.New(50005, "invalid claims type")
 )

+ 53 - 11
internal/user/user.go

@@ -1,14 +1,16 @@
 package user
 
 import (
+	"crypto/rand"
+	"encoding/base64"
+	"time"
+
 	"github.com/0xJacky/Nginx-UI/model"
 	"github.com/0xJacky/Nginx-UI/query"
 	"github.com/golang-jwt/jwt/v5"
-	"github.com/pkg/errors"
 	"github.com/spf13/cast"
 	"github.com/uozi-tech/cosy/logger"
 	cSettings "github.com/uozi-tech/cosy/settings"
-	"time"
 )
 
 const ExpiredTime = 24 * time.Hour
@@ -57,7 +59,34 @@ func GetTokenUser(token string) (*model.User, bool) {
 	return user, err == nil
 }
 
-func GenerateJWT(user *model.User) (string, error) {
+func GetTokenUserByShortToken(shortToken string) (*model.User, bool) {
+	if shortToken == "" {
+		return nil, false
+	}
+
+	db := model.UseDB()
+	var authToken model.AuthToken
+	err := db.Where("short_token = ?", shortToken).First(&authToken).Error
+	if err != nil {
+		return nil, false
+	}
+
+	if authToken.ExpiredAt < time.Now().Unix() {
+		DeleteToken(authToken.Token)
+		return nil, false
+	}
+
+	u := query.User
+	user, err := u.FirstByID(authToken.UserID)
+	return user, err == nil
+}
+
+type AccessTokenPayload struct {
+	Token      string `json:"token,omitempty"`
+	ShortToken string `json:"short_token,omitempty"`
+}
+
+func GenerateJWT(user *model.User) (*AccessTokenPayload, error) {
 	now := time.Now()
 	claims := JWTClaims{
 		Name:   user.Name,
@@ -75,26 +104,39 @@ func GenerateJWT(user *model.User) (string, error) {
 	unsignedToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
 	signedToken, err := unsignedToken.SignedString([]byte(cSettings.AppSettings.JwtSecret))
 	if err != nil {
-		return "", err
+		return nil, err
+	}
+
+	// Generate 16-byte short token (16 characters)
+	shortTokenBytes := make([]byte, 16)
+	_, err = rand.Read(shortTokenBytes)
+	if err != nil {
+		return nil, err
 	}
+	// Use base64 URL encoding to get a 16-character string
+	shortToken := base64.URLEncoding.EncodeToString(shortTokenBytes)[:16]
 
 	q := query.AuthToken
 	err = q.Create(&model.AuthToken{
-		UserID:    user.ID,
-		Token:     signedToken,
-		ExpiredAt: now.Add(ExpiredTime).Unix(),
+		UserID:     user.ID,
+		Token:      signedToken,
+		ShortToken: shortToken,
+		ExpiredAt:  now.Add(ExpiredTime).Unix(),
 	})
 
 	if err != nil {
-		return "", err
+		return nil, err
 	}
 
-	return signedToken, err
+	return &AccessTokenPayload{
+		Token:      signedToken,
+		ShortToken: shortToken,
+	}, nil
 }
 
 func ValidateJWT(tokenStr string) (claims *JWTClaims, err error) {
 	if tokenStr == "" {
-		err = errors.New("token is empty")
+		err = ErrTokenIsEmpty
 		return
 	}
 	token, err := jwt.ParseWithClaims(tokenStr, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) {
@@ -107,5 +149,5 @@ func ValidateJWT(tokenStr string) (claims *JWTClaims, err error) {
 	if claims, ok = token.Claims.(*JWTClaims); ok && token.Valid {
 		return claims, nil
 	}
-	return nil, errors.New("invalid claims type")
+	return nil, ErrInvalidClaimsType
 }

+ 4 - 3
model/user.go

@@ -36,9 +36,10 @@ type User struct {
 }
 
 type AuthToken struct {
-	UserID    uint64 `json:"user_id"`
-	Token     string `json:"token"`
-	ExpiredAt int64  `json:"expired_at" gorm:"default:0"`
+	UserID     uint64 `json:"user_id"`
+	Token      string `json:"token"`
+	ShortToken string `json:"short_token"`
+	ExpiredAt  int64  `json:"expired_at" gorm:"default:0"`
 }
 
 func (u *User) TableName() string {

+ 9 - 5
query/auth_tokens.gen.go

@@ -30,6 +30,7 @@ func newAuthToken(db *gorm.DB, opts ...gen.DOOption) authToken {
 	_authToken.ALL = field.NewAsterisk(tableName)
 	_authToken.UserID = field.NewUint64(tableName, "user_id")
 	_authToken.Token = field.NewString(tableName, "token")
+	_authToken.ShortToken = field.NewString(tableName, "short_token")
 	_authToken.ExpiredAt = field.NewInt64(tableName, "expired_at")
 
 	_authToken.fillFieldMap()
@@ -40,10 +41,11 @@ func newAuthToken(db *gorm.DB, opts ...gen.DOOption) authToken {
 type authToken struct {
 	authTokenDo
 
-	ALL       field.Asterisk
-	UserID    field.Uint64
-	Token     field.String
-	ExpiredAt field.Int64
+	ALL        field.Asterisk
+	UserID     field.Uint64
+	Token      field.String
+	ShortToken field.String
+	ExpiredAt  field.Int64
 
 	fieldMap map[string]field.Expr
 }
@@ -62,6 +64,7 @@ func (a *authToken) updateTableName(table string) *authToken {
 	a.ALL = field.NewAsterisk(table)
 	a.UserID = field.NewUint64(table, "user_id")
 	a.Token = field.NewString(table, "token")
+	a.ShortToken = field.NewString(table, "short_token")
 	a.ExpiredAt = field.NewInt64(table, "expired_at")
 
 	a.fillFieldMap()
@@ -79,9 +82,10 @@ func (a *authToken) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
 }
 
 func (a *authToken) fillFieldMap() {
-	a.fieldMap = make(map[string]field.Expr, 3)
+	a.fieldMap = make(map[string]field.Expr, 4)
 	a.fieldMap["user_id"] = a.UserID
 	a.fieldMap["token"] = a.Token
+	a.fieldMap["short_token"] = a.ShortToken
 	a.fieldMap["expired_at"] = a.ExpiredAt
 }