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
This commit is contained in:
CaIon
2026-04-12 17:39:00 +08:00
parent 2819e3a1d1
commit 59c582d13c
10 changed files with 144 additions and 55 deletions
+12
View File
@@ -28,6 +28,18 @@ const (
MsgBatchTooMany = "common.batch_too_many" 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 // Token related messages
const ( const (
MsgTokenNameTooLong = "token.name_too_long" MsgTokenNameTooLong = "token.name_too_long"
+10
View File
@@ -23,6 +23,16 @@ common.already_exists: "Already exists"
common.name_cannot_be_empty: "Name cannot be empty" common.name_cannot_be_empty: "Name cannot be empty"
common.batch_too_many: "Too many items in batch request, maximum is {{.Max}}" 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 messages
token.name_too_long: "Token name is too long" token.name_too_long: "Token name is too long"
token.quota_negative: "Quota value cannot be negative" token.quota_negative: "Quota value cannot be negative"
+10
View File
@@ -24,6 +24,16 @@ common.already_exists: "已存在"
common.name_cannot_be_empty: "名称不能为空" common.name_cannot_be_empty: "名称不能为空"
common.batch_too_many: "批量请求数量过多,最多 {{.Max}} 条" 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 messages
token.name_too_long: "令牌名称过长" token.name_too_long: "令牌名称过长"
token.quota_negative: "额度值不能为负数" token.quota_negative: "额度值不能为负数"
+10
View File
@@ -24,6 +24,16 @@ common.already_exists: "已存在"
common.name_cannot_be_empty: "名稱不能為空" common.name_cannot_be_empty: "名稱不能為空"
common.batch_too_many: "批次請求數量過多,最多 {{.Max}} 條" 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 messages
token.name_too_long: "令牌名稱過長" token.name_too_long: "令牌名稱過長"
token.quota_negative: "額度值不能為負數" token.quota_negative: "額度值不能為負數"
+57 -20
View File
@@ -1,6 +1,7 @@
package middleware package middleware
import ( import (
"errors"
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
@@ -9,6 +10,7 @@ import (
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/service"
@@ -17,6 +19,7 @@ import (
"github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm"
) )
func validUserInfo(username string, role int) bool { func validUserInfo(username string, role int) bool {
@@ -43,17 +46,33 @@ func authHelper(c *gin.Context, minRole int) {
if accessToken == "" { if accessToken == "" {
c.JSON(http.StatusUnauthorized, gin.H{ c.JSON(http.StatusUnauthorized, gin.H{
"success": false, "success": false,
"message": "无权进行此操作,未登录且未提供 access token", "message": common.TranslateMessage(c, i18n.MsgAuthNotLoggedIn),
}) })
c.Abort() c.Abort()
return 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 user != nil && user.Username != "" {
if !validUserInfo(user.Username, user.Role) { if !validUserInfo(user.Username, user.Role) {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": false, "success": false,
"message": "无权进行此操作,用户信息无效", "message": common.TranslateMessage(c, i18n.MsgAuthUserInfoInvalid),
}) })
c.Abort() c.Abort()
return return
@@ -67,7 +86,7 @@ func authHelper(c *gin.Context, minRole int) {
} else { } else {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": false, "success": false,
"message": "无权进行此操作,access token 无效", "message": common.TranslateMessage(c, i18n.MsgAuthAccessTokenInvalid),
}) })
c.Abort() c.Abort()
return return
@@ -78,7 +97,7 @@ func authHelper(c *gin.Context, minRole int) {
if apiUserIdStr == "" { if apiUserIdStr == "" {
c.JSON(http.StatusUnauthorized, gin.H{ c.JSON(http.StatusUnauthorized, gin.H{
"success": false, "success": false,
"message": "无权进行此操作,未提供 New-Api-User", "message": common.TranslateMessage(c, i18n.MsgAuthUserIdNotProvided),
}) })
c.Abort() c.Abort()
return return
@@ -87,7 +106,7 @@ func authHelper(c *gin.Context, minRole int) {
if err != nil { if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{ c.JSON(http.StatusUnauthorized, gin.H{
"success": false, "success": false,
"message": "无权进行此操作,New-Api-User 格式错误", "message": common.TranslateMessage(c, i18n.MsgAuthUserIdFormatError),
}) })
c.Abort() c.Abort()
return return
@@ -96,7 +115,7 @@ func authHelper(c *gin.Context, minRole int) {
if id != apiUserId { if id != apiUserId {
c.JSON(http.StatusUnauthorized, gin.H{ c.JSON(http.StatusUnauthorized, gin.H{
"success": false, "success": false,
"message": "无权进行此操作,New-Api-User 与登录用户不匹配", "message": common.TranslateMessage(c, i18n.MsgAuthUserIdMismatch),
}) })
c.Abort() c.Abort()
return return
@@ -104,7 +123,7 @@ func authHelper(c *gin.Context, minRole int) {
if status.(int) == common.UserStatusDisabled { if status.(int) == common.UserStatusDisabled {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": false, "success": false,
"message": "用户已被封禁", "message": common.TranslateMessage(c, i18n.MsgAuthUserBanned),
}) })
c.Abort() c.Abort()
return return
@@ -112,7 +131,7 @@ func authHelper(c *gin.Context, minRole int) {
if role.(int) < minRole { if role.(int) < minRole {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": false, "success": false,
"message": "无权进行此操作,权限不足", "message": common.TranslateMessage(c, i18n.MsgAuthInsufficientPrivilege),
}) })
c.Abort() c.Abort()
return return
@@ -120,7 +139,7 @@ func authHelper(c *gin.Context, minRole int) {
if !validUserInfo(username.(string), role.(int)) { if !validUserInfo(username.(string), role.(int)) {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": false, "success": false,
"message": "无权进行此操作,用户信息无效", "message": common.TranslateMessage(c, i18n.MsgAuthUserInfoInvalid),
}) })
c.Abort() c.Abort()
return return
@@ -198,7 +217,7 @@ func TokenAuthReadOnly() func(c *gin.Context) {
if key == "" { if key == "" {
c.JSON(http.StatusUnauthorized, gin.H{ c.JSON(http.StatusUnauthorized, gin.H{
"success": false, "success": false,
"message": "未提供 Authorization 请求头", "message": common.TranslateMessage(c, i18n.MsgTokenNotProvided),
}) })
c.Abort() c.Abort()
return return
@@ -212,19 +231,28 @@ func TokenAuthReadOnly() func(c *gin.Context) {
token, err := model.GetTokenByKey(key, false) token, err := model.GetTokenByKey(key, false)
if err != nil { if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{ if errors.Is(err, gorm.ErrRecordNotFound) {
"success": false, c.JSON(http.StatusUnauthorized, gin.H{
"message": "无效的令牌", "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() c.Abort()
return return
} }
userCache, err := model.GetUserCache(token.UserId) userCache, err := model.GetUserCache(token.UserId)
if err != nil { if err != nil {
common.SysLog(fmt.Sprintf("TokenAuthReadOnly GetUserCache error for user %d: %v", token.UserId, err))
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"message": err.Error(), "message": common.TranslateMessage(c, i18n.MsgDatabaseError),
}) })
c.Abort() c.Abort()
return return
@@ -232,7 +260,7 @@ func TokenAuthReadOnly() func(c *gin.Context) {
if userCache.Status != common.UserStatusEnabled { if userCache.Status != common.UserStatusEnabled {
c.JSON(http.StatusForbidden, gin.H{ c.JSON(http.StatusForbidden, gin.H{
"success": false, "success": false,
"message": "用户已被封禁", "message": common.TranslateMessage(c, i18n.MsgAuthUserBanned),
}) })
c.Abort() c.Abort()
return return
@@ -309,7 +337,14 @@ func TokenAuth() func(c *gin.Context) {
} }
} }
if err != nil { 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 return
} }
@@ -331,12 +366,14 @@ func TokenAuth() func(c *gin.Context) {
userCache, err := model.GetUserCache(token.UserId) userCache, err := model.GetUserCache(token.UserId)
if err != nil { 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 return
} }
userEnabled := userCache.Status == common.UserStatusEnabled userEnabled := userCache.Status == common.UserStatusEnabled
if !userEnabled { if !userEnabled {
abortWithOpenAiMessage(c, http.StatusForbidden, "用户已被封禁") abortWithOpenAiMessage(c, http.StatusForbidden, common.TranslateMessage(c, i18n.MsgAuthUserBanned))
return return
} }
+26
View File
@@ -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")
-3
View File
@@ -11,9 +11,6 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// ErrRedeemFailed is returned when redemption fails due to database error
var ErrRedeemFailed = errors.New("redeem.failed")
type Redemption struct { type Redemption struct {
Id int `json:"id"` Id int `json:"id"`
UserId int `json:"user_id"` UserId int `json:"user_id"`
+9 -18
View File
@@ -187,19 +187,14 @@ func SearchUserTokens(userId int, keyword string, token string, offset int, limi
func ValidateUserToken(key string) (token *Token, err error) { func ValidateUserToken(key string) (token *Token, err error) {
if key == "" { if key == "" {
return nil, errors.New("未提供令牌") return nil, ErrTokenNotProvided
} }
token, err = GetTokenByKey(key, false) token, err = GetTokenByKey(key, false)
if err == nil { if err == nil {
if token.Status == common.TokenStatusExhausted { if token.Status == common.TokenStatusExhausted ||
keyPrefix := key[:3] token.Status == common.TokenStatusExpired ||
keySuffix := key[len(key)-3:] token.Status != common.TokenStatusEnabled {
return token, errors.New("该令牌额度已用尽 TokenStatusExhausted[sk-" + keyPrefix + "***" + keySuffix + "]") return token, ErrTokenInvalid
} else if token.Status == common.TokenStatusExpired {
return token, errors.New("该令牌已过期")
}
if token.Status != common.TokenStatusEnabled {
return token, errors.New("该令牌状态不可用")
} }
if token.ExpiredTime != -1 && token.ExpiredTime < common.GetTimestamp() { if token.ExpiredTime != -1 && token.ExpiredTime < common.GetTimestamp() {
if !common.RedisEnabled { if !common.RedisEnabled {
@@ -209,29 +204,25 @@ func ValidateUserToken(key string) (token *Token, err error) {
common.SysLog("failed to update token status" + 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 !token.UnlimitedQuota && token.RemainQuota <= 0 {
if !common.RedisEnabled { if !common.RedisEnabled {
// in this case, we can make sure the token is exhausted
token.Status = common.TokenStatusExhausted token.Status = common.TokenStatusExhausted
err := token.SelectUpdate() err := token.SelectUpdate()
if err != nil { if err != nil {
common.SysLog("failed to update token status" + err.Error()) common.SysLog("failed to update token status" + err.Error())
} }
} }
keyPrefix := key[:3] return token, ErrTokenInvalid
keySuffix := key[len(key)-3:]
return token, fmt.Errorf("[sk-%s***%s] 该令牌额度已用尽 !token.UnlimitedQuota && token.RemainQuota = %d", keyPrefix, keySuffix, token.RemainQuota)
} }
return token, nil return token, nil
} }
common.SysLog("ValidateUserToken: failed to get token: " + err.Error()) common.SysLog("ValidateUserToken: failed to get token: " + err.Error())
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("无效的令牌") return nil, ErrTokenInvalid
} else {
return nil, errors.New("无效的令牌,数据库查询出错,请联系管理员")
} }
return nil, fmt.Errorf("%w: %v", ErrDatabase, err)
} }
func GetTokenByIds(id int, userId int) (*Token, error) { func GetTokenByIds(id int, userId int) (*Token, error) {
-2
View File
@@ -10,8 +10,6 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
var ErrTwoFANotEnabled = errors.New("用户未启用2FA")
// TwoFA 用户2FA设置表 // TwoFA 用户2FA设置表
type TwoFA struct { type TwoFA struct {
Id int `json:"id" gorm:"primaryKey"` Id int `json:"id" gorm:"primaryKey"`
+10 -12
View File
@@ -18,12 +18,6 @@ import (
const UserNameMaxLength = 20 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. // 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! // Otherwise, the sensitive information will be saved on local storage in plain text!
type User struct { type User struct {
@@ -766,16 +760,20 @@ func IsAdmin(userId int) bool {
// return user.Status == common.UserStatusEnabled, nil // return user.Status == common.UserStatusEnabled, nil
//} //}
func ValidateAccessToken(token string) (user *User) { func ValidateAccessToken(token string) (*User, error) {
if token == "" { if token == "" {
return nil return nil, nil
} }
token = strings.Replace(token, "Bearer ", "", 1) token = strings.Replace(token, "Bearer ", "", 1)
user = &User{} user := &User{}
if DB.Where("access_token = ?", token).First(user).RowsAffected == 1 { err := DB.Where("access_token = ?", token).First(user).Error
return user 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 // GetUserQuota gets quota from Redis first, falls back to DB if needed