Просмотр исходного кода

fix: harden token auth error handling to prevent info leakage

- Create model/errors.go to centralize all sentinel errors
- ValidateAccessToken now returns error to distinguish DB failures
- ValidateUserToken uses unified ErrTokenInvalid for all auth failures
  (expired/exhausted/disabled/not-found) to prevent token enumeration
- authHelper and TokenAuthReadOnly use i18n messages instead of
  hardcoded Chinese strings
- All err.Error() removed from user-facing responses; DB errors logged
  server-side and return generic "contact admin" message (HTTP 500)
- Migrate ErrRedeemFailed, ErrTwoFANotEnabled to model/errors.go
CaIon 1 день назад
Родитель
Сommit
59c582d13c
10 измененных файлов с 144 добавлено и 55 удалено
  1. 12 0
      i18n/keys.go
  2. 10 0
      i18n/locales/en.yaml
  3. 10 0
      i18n/locales/zh-CN.yaml
  4. 10 0
      i18n/locales/zh-TW.yaml
  5. 57 20
      middleware/auth.go
  6. 26 0
      model/errors.go
  7. 0 3
      model/redemption.go
  8. 9 18
      model/token.go
  9. 0 2
      model/twofa.go
  10. 10 12
      model/user.go

+ 12 - 0
i18n/keys.go

@@ -28,6 +28,18 @@ const (
 	MsgBatchTooMany      = "common.batch_too_many"
 )
 
+// Auth middleware messages
+const (
+	MsgAuthNotLoggedIn           = "auth.not_logged_in"
+	MsgAuthAccessTokenInvalid    = "auth.access_token_invalid"
+	MsgAuthUserInfoInvalid       = "auth.user_info_invalid"
+	MsgAuthUserIdNotProvided     = "auth.user_id_not_provided"
+	MsgAuthUserIdFormatError     = "auth.user_id_format_error"
+	MsgAuthUserIdMismatch        = "auth.user_id_mismatch"
+	MsgAuthUserBanned            = "auth.user_banned"
+	MsgAuthInsufficientPrivilege = "auth.insufficient_privilege"
+)
+
 // Token related messages
 const (
 	MsgTokenNameTooLong          = "token.name_too_long"

+ 10 - 0
i18n/locales/en.yaml

@@ -23,6 +23,16 @@ common.already_exists: "Already exists"
 common.name_cannot_be_empty: "Name cannot be empty"
 common.batch_too_many: "Too many items in batch request, maximum is {{.Max}}"
 
+# Auth middleware messages
+auth.not_logged_in: "Unauthorized, not logged in and no access token provided"
+auth.access_token_invalid: "Unauthorized, invalid access token"
+auth.user_info_invalid: "Unauthorized, invalid user info"
+auth.user_id_not_provided: "Unauthorized, New-Api-User header not provided"
+auth.user_id_format_error: "Unauthorized, New-Api-User header format error"
+auth.user_id_mismatch: "Unauthorized, New-Api-User does not match logged in user"
+auth.user_banned: "User has been banned"
+auth.insufficient_privilege: "Unauthorized, insufficient privileges"
+
 # Token messages
 token.name_too_long: "Token name is too long"
 token.quota_negative: "Quota value cannot be negative"

+ 10 - 0
i18n/locales/zh-CN.yaml

@@ -24,6 +24,16 @@ common.already_exists: "已存在"
 common.name_cannot_be_empty: "名称不能为空"
 common.batch_too_many: "批量请求数量过多,最多 {{.Max}} 条"
 
+# Auth middleware messages
+auth.not_logged_in: "无权进行此操作,未登录且未提供 access token"
+auth.access_token_invalid: "无权进行此操作,access token 无效"
+auth.user_info_invalid: "无权进行此操作,用户信息无效"
+auth.user_id_not_provided: "无权进行此操作,未提供 New-Api-User"
+auth.user_id_format_error: "无权进行此操作,New-Api-User 格式错误"
+auth.user_id_mismatch: "无权进行此操作,New-Api-User 与登录用户不匹配"
+auth.user_banned: "用户已被封禁"
+auth.insufficient_privilege: "无权进行此操作,权限不足"
+
 # Token messages
 token.name_too_long: "令牌名称过长"
 token.quota_negative: "额度值不能为负数"

+ 10 - 0
i18n/locales/zh-TW.yaml

@@ -24,6 +24,16 @@ common.already_exists: "已存在"
 common.name_cannot_be_empty: "名稱不能為空"
 common.batch_too_many: "批次請求數量過多,最多 {{.Max}} 條"
 
+# Auth middleware messages
+auth.not_logged_in: "無權進行此操作,未登入且未提供 access token"
+auth.access_token_invalid: "無權進行此操作,access token 無效"
+auth.user_info_invalid: "無權進行此操作,使用者資訊無效"
+auth.user_id_not_provided: "無權進行此操作,未提供 New-Api-User"
+auth.user_id_format_error: "無權進行此操作,New-Api-User 格式錯誤"
+auth.user_id_mismatch: "無權進行此操作,New-Api-User 與登入使用者不匹配"
+auth.user_banned: "使用者已被封禁"
+auth.insufficient_privilege: "無權進行此操作,權限不足"
+
 # Token messages
 token.name_too_long: "令牌名稱過長"
 token.quota_negative: "額度值不能為負數"

+ 57 - 20
middleware/auth.go

@@ -1,6 +1,7 @@
 package middleware
 
 import (
+	"errors"
 	"fmt"
 	"net"
 	"net/http"
@@ -9,6 +10,7 @@ import (
 
 	"github.com/QuantumNous/new-api/common"
 	"github.com/QuantumNous/new-api/constant"
+	"github.com/QuantumNous/new-api/i18n"
 	"github.com/QuantumNous/new-api/logger"
 	"github.com/QuantumNous/new-api/model"
 	"github.com/QuantumNous/new-api/service"
@@ -17,6 +19,7 @@ import (
 
 	"github.com/gin-contrib/sessions"
 	"github.com/gin-gonic/gin"
+	"gorm.io/gorm"
 )
 
 func validUserInfo(username string, role int) bool {
@@ -43,17 +46,33 @@ func authHelper(c *gin.Context, minRole int) {
 		if accessToken == "" {
 			c.JSON(http.StatusUnauthorized, gin.H{
 				"success": false,
-				"message": "无权进行此操作,未登录且未提供 access token",
+				"message": common.TranslateMessage(c, i18n.MsgAuthNotLoggedIn),
 			})
 			c.Abort()
 			return
 		}
-		user := model.ValidateAccessToken(accessToken)
+		user, authErr := model.ValidateAccessToken(accessToken)
+		if authErr != nil {
+			if errors.Is(authErr, model.ErrDatabase) {
+				common.SysLog("ValidateAccessToken database error: " + authErr.Error())
+				c.JSON(http.StatusInternalServerError, gin.H{
+					"success": false,
+					"message": common.TranslateMessage(c, i18n.MsgDatabaseError),
+				})
+			} else {
+				c.JSON(http.StatusOK, gin.H{
+					"success": false,
+					"message": common.TranslateMessage(c, i18n.MsgAuthAccessTokenInvalid),
+				})
+			}
+			c.Abort()
+			return
+		}
 		if user != nil && user.Username != "" {
 			if !validUserInfo(user.Username, user.Role) {
 				c.JSON(http.StatusOK, gin.H{
 					"success": false,
-					"message": "无权进行此操作,用户信息无效",
+					"message": common.TranslateMessage(c, i18n.MsgAuthUserInfoInvalid),
 				})
 				c.Abort()
 				return
@@ -67,7 +86,7 @@ func authHelper(c *gin.Context, minRole int) {
 		} else {
 			c.JSON(http.StatusOK, gin.H{
 				"success": false,
-				"message": "无权进行此操作,access token 无效",
+				"message": common.TranslateMessage(c, i18n.MsgAuthAccessTokenInvalid),
 			})
 			c.Abort()
 			return
@@ -78,7 +97,7 @@ func authHelper(c *gin.Context, minRole int) {
 	if apiUserIdStr == "" {
 		c.JSON(http.StatusUnauthorized, gin.H{
 			"success": false,
-			"message": "无权进行此操作,未提供 New-Api-User",
+			"message": common.TranslateMessage(c, i18n.MsgAuthUserIdNotProvided),
 		})
 		c.Abort()
 		return
@@ -87,7 +106,7 @@ func authHelper(c *gin.Context, minRole int) {
 	if err != nil {
 		c.JSON(http.StatusUnauthorized, gin.H{
 			"success": false,
-			"message": "无权进行此操作,New-Api-User 格式错误",
+			"message": common.TranslateMessage(c, i18n.MsgAuthUserIdFormatError),
 		})
 		c.Abort()
 		return
@@ -96,7 +115,7 @@ func authHelper(c *gin.Context, minRole int) {
 	if id != apiUserId {
 		c.JSON(http.StatusUnauthorized, gin.H{
 			"success": false,
-			"message": "无权进行此操作,New-Api-User 与登录用户不匹配",
+			"message": common.TranslateMessage(c, i18n.MsgAuthUserIdMismatch),
 		})
 		c.Abort()
 		return
@@ -104,7 +123,7 @@ func authHelper(c *gin.Context, minRole int) {
 	if status.(int) == common.UserStatusDisabled {
 		c.JSON(http.StatusOK, gin.H{
 			"success": false,
-			"message": "用户已被封禁",
+			"message": common.TranslateMessage(c, i18n.MsgAuthUserBanned),
 		})
 		c.Abort()
 		return
@@ -112,7 +131,7 @@ func authHelper(c *gin.Context, minRole int) {
 	if role.(int) < minRole {
 		c.JSON(http.StatusOK, gin.H{
 			"success": false,
-			"message": "无权进行此操作,权限不足",
+			"message": common.TranslateMessage(c, i18n.MsgAuthInsufficientPrivilege),
 		})
 		c.Abort()
 		return
@@ -120,7 +139,7 @@ func authHelper(c *gin.Context, minRole int) {
 	if !validUserInfo(username.(string), role.(int)) {
 		c.JSON(http.StatusOK, gin.H{
 			"success": false,
-			"message": "无权进行此操作,用户信息无效",
+			"message": common.TranslateMessage(c, i18n.MsgAuthUserInfoInvalid),
 		})
 		c.Abort()
 		return
@@ -198,7 +217,7 @@ func TokenAuthReadOnly() func(c *gin.Context) {
 		if key == "" {
 			c.JSON(http.StatusUnauthorized, gin.H{
 				"success": false,
-				"message": "未提供 Authorization 请求头",
+				"message": common.TranslateMessage(c, i18n.MsgTokenNotProvided),
 			})
 			c.Abort()
 			return
@@ -212,19 +231,28 @@ func TokenAuthReadOnly() func(c *gin.Context) {
 
 		token, err := model.GetTokenByKey(key, false)
 		if err != nil {
-			c.JSON(http.StatusUnauthorized, gin.H{
-				"success": false,
-				"message": "无效的令牌",
-			})
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				c.JSON(http.StatusUnauthorized, gin.H{
+					"success": false,
+					"message": common.TranslateMessage(c, i18n.MsgTokenInvalid),
+				})
+			} else {
+				common.SysLog("TokenAuthReadOnly GetTokenByKey database error: " + err.Error())
+				c.JSON(http.StatusInternalServerError, gin.H{
+					"success": false,
+					"message": common.TranslateMessage(c, i18n.MsgDatabaseError),
+				})
+			}
 			c.Abort()
 			return
 		}
 
 		userCache, err := model.GetUserCache(token.UserId)
 		if err != nil {
+			common.SysLog(fmt.Sprintf("TokenAuthReadOnly GetUserCache error for user %d: %v", token.UserId, err))
 			c.JSON(http.StatusInternalServerError, gin.H{
 				"success": false,
-				"message": err.Error(),
+				"message": common.TranslateMessage(c, i18n.MsgDatabaseError),
 			})
 			c.Abort()
 			return
@@ -232,7 +260,7 @@ func TokenAuthReadOnly() func(c *gin.Context) {
 		if userCache.Status != common.UserStatusEnabled {
 			c.JSON(http.StatusForbidden, gin.H{
 				"success": false,
-				"message": "用户已被封禁",
+				"message": common.TranslateMessage(c, i18n.MsgAuthUserBanned),
 			})
 			c.Abort()
 			return
@@ -309,7 +337,14 @@ func TokenAuth() func(c *gin.Context) {
 			}
 		}
 		if err != nil {
-			abortWithOpenAiMessage(c, http.StatusUnauthorized, err.Error())
+			if errors.Is(err, model.ErrDatabase) {
+				common.SysLog("TokenAuth ValidateUserToken database error: " + err.Error())
+				abortWithOpenAiMessage(c, http.StatusInternalServerError,
+					common.TranslateMessage(c, i18n.MsgDatabaseError))
+			} else {
+				abortWithOpenAiMessage(c, http.StatusUnauthorized,
+					common.TranslateMessage(c, i18n.MsgTokenInvalid))
+			}
 			return
 		}
 
@@ -331,12 +366,14 @@ func TokenAuth() func(c *gin.Context) {
 
 		userCache, err := model.GetUserCache(token.UserId)
 		if err != nil {
-			abortWithOpenAiMessage(c, http.StatusInternalServerError, err.Error())
+			common.SysLog(fmt.Sprintf("TokenAuth GetUserCache error for user %d: %v", token.UserId, err))
+			abortWithOpenAiMessage(c, http.StatusInternalServerError,
+				common.TranslateMessage(c, i18n.MsgDatabaseError))
 			return
 		}
 		userEnabled := userCache.Status == common.UserStatusEnabled
 		if !userEnabled {
-			abortWithOpenAiMessage(c, http.StatusForbidden, "用户已被封禁")
+			abortWithOpenAiMessage(c, http.StatusForbidden, common.TranslateMessage(c, i18n.MsgAuthUserBanned))
 			return
 		}
 

+ 26 - 0
model/errors.go

@@ -0,0 +1,26 @@
+package model
+
+import "errors"
+
+// Common errors
+var (
+	ErrDatabase = errors.New("database error")
+)
+
+// User auth errors
+var (
+	ErrInvalidCredentials   = errors.New("invalid credentials")
+	ErrUserEmptyCredentials = errors.New("empty credentials")
+)
+
+// Token auth errors
+var (
+	ErrTokenNotProvided = errors.New("token not provided")
+	ErrTokenInvalid     = errors.New("token invalid")
+)
+
+// Redemption errors
+var ErrRedeemFailed = errors.New("redeem.failed")
+
+// 2FA errors
+var ErrTwoFANotEnabled = errors.New("2fa not enabled")

+ 0 - 3
model/redemption.go

@@ -11,9 +11,6 @@ import (
 	"gorm.io/gorm"
 )
 
-// ErrRedeemFailed is returned when redemption fails due to database error
-var ErrRedeemFailed = errors.New("redeem.failed")
-
 type Redemption struct {
 	Id           int            `json:"id"`
 	UserId       int            `json:"user_id"`

+ 9 - 18
model/token.go

@@ -187,19 +187,14 @@ func SearchUserTokens(userId int, keyword string, token string, offset int, limi
 
 func ValidateUserToken(key string) (token *Token, err error) {
 	if key == "" {
-		return nil, errors.New("未提供令牌")
+		return nil, ErrTokenNotProvided
 	}
 	token, err = GetTokenByKey(key, false)
 	if err == nil {
-		if token.Status == common.TokenStatusExhausted {
-			keyPrefix := key[:3]
-			keySuffix := key[len(key)-3:]
-			return token, errors.New("该令牌额度已用尽 TokenStatusExhausted[sk-" + keyPrefix + "***" + keySuffix + "]")
-		} else if token.Status == common.TokenStatusExpired {
-			return token, errors.New("该令牌已过期")
-		}
-		if token.Status != common.TokenStatusEnabled {
-			return token, errors.New("该令牌状态不可用")
+		if token.Status == common.TokenStatusExhausted ||
+			token.Status == common.TokenStatusExpired ||
+			token.Status != common.TokenStatusEnabled {
+			return token, ErrTokenInvalid
 		}
 		if token.ExpiredTime != -1 && token.ExpiredTime < common.GetTimestamp() {
 			if !common.RedisEnabled {
@@ -209,29 +204,25 @@ func ValidateUserToken(key string) (token *Token, err error) {
 					common.SysLog("failed to update token status" + err.Error())
 				}
 			}
-			return token, errors.New("该令牌已过期")
+			return token, ErrTokenInvalid
 		}
 		if !token.UnlimitedQuota && token.RemainQuota <= 0 {
 			if !common.RedisEnabled {
-				// in this case, we can make sure the token is exhausted
 				token.Status = common.TokenStatusExhausted
 				err := token.SelectUpdate()
 				if err != nil {
 					common.SysLog("failed to update token status" + err.Error())
 				}
 			}
-			keyPrefix := key[:3]
-			keySuffix := key[len(key)-3:]
-			return token, fmt.Errorf("[sk-%s***%s] 该令牌额度已用尽 !token.UnlimitedQuota && token.RemainQuota = %d", keyPrefix, keySuffix, token.RemainQuota)
+			return token, ErrTokenInvalid
 		}
 		return token, nil
 	}
 	common.SysLog("ValidateUserToken: failed to get token: " + err.Error())
 	if errors.Is(err, gorm.ErrRecordNotFound) {
-		return nil, errors.New("无效的令牌")
-	} else {
-		return nil, errors.New("无效的令牌,数据库查询出错,请联系管理员")
+		return nil, ErrTokenInvalid
 	}
+	return nil, fmt.Errorf("%w: %v", ErrDatabase, err)
 }
 
 func GetTokenByIds(id int, userId int) (*Token, error) {

+ 0 - 2
model/twofa.go

@@ -10,8 +10,6 @@ import (
 	"gorm.io/gorm"
 )
 
-var ErrTwoFANotEnabled = errors.New("用户未启用2FA")
-
 // TwoFA 用户2FA设置表
 type TwoFA struct {
 	Id             int            `json:"id" gorm:"primaryKey"`

+ 10 - 12
model/user.go

@@ -18,12 +18,6 @@ import (
 
 const UserNameMaxLength = 20
 
-var (
-	ErrDatabase              = errors.New("database error")
-	ErrInvalidCredentials    = errors.New("invalid credentials")
-	ErrUserEmptyCredentials  = errors.New("empty credentials")
-)
-
 // User if you add sensitive fields, don't forget to clean them in setupLogin function.
 // Otherwise, the sensitive information will be saved on local storage in plain text!
 type User struct {
@@ -766,16 +760,20 @@ func IsAdmin(userId int) bool {
 //	return user.Status == common.UserStatusEnabled, nil
 //}
 
-func ValidateAccessToken(token string) (user *User) {
+func ValidateAccessToken(token string) (*User, error) {
 	if token == "" {
-		return nil
+		return nil, nil
 	}
 	token = strings.Replace(token, "Bearer ", "", 1)
-	user = &User{}
-	if DB.Where("access_token = ?", token).First(user).RowsAffected == 1 {
-		return user
+	user := &User{}
+	err := DB.Where("access_token = ?", token).First(user).Error
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			return nil, nil
+		}
+		return nil, fmt.Errorf("%w: %v", ErrDatabase, err)
 	}
-	return nil
+	return user, nil
 }
 
 // GetUserQuota gets quota from Redis first, falls back to DB if needed