refactor(auth): replace dashboard sessions with stateless tokens and session control (#6329)
* refactor(auth): replace dashboard sessions with stateless tokens * feat(auth): harden session issuance and distributed enforcement * fix(proxy): preserve trusted proxy compatibility defaults * refactor: address dashboard auth review feedback * refactor: remove classic frontend and flatten web app
This commit is contained in:
+160
-122
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
@@ -18,11 +17,20 @@ import (
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const authIdentityContextKey = "auth_identity"
|
||||
|
||||
type dashboardCredentialKind int
|
||||
|
||||
const (
|
||||
dashboardCredentialUnmatched dashboardCredentialKind = iota
|
||||
dashboardCredentialInternal
|
||||
dashboardCredentialPAT
|
||||
)
|
||||
|
||||
func validUserInfo(username string, role int) bool {
|
||||
// check username is empty
|
||||
if strings.TrimSpace(username) == "" {
|
||||
@@ -35,124 +43,24 @@ func validUserInfo(username string, role int) bool {
|
||||
}
|
||||
|
||||
func authHelper(c *gin.Context, minRole int) {
|
||||
session := sessions.Default(c)
|
||||
username := session.Get("username")
|
||||
role := session.Get("role")
|
||||
id := session.Get("id")
|
||||
status := session.Get("status")
|
||||
useAccessToken := false
|
||||
if username == nil {
|
||||
// Check access token
|
||||
accessToken := c.Request.Header.Get("Authorization")
|
||||
if accessToken == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": common.TranslateMessage(c, i18n.MsgAuthNotLoggedIn),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
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": common.TranslateMessage(c, i18n.MsgAuthUserInfoInvalid),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
// Token is valid
|
||||
username = user.Username
|
||||
role = user.Role
|
||||
id = user.Id
|
||||
status = user.Status
|
||||
useAccessToken = true
|
||||
} else {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": common.TranslateMessage(c, i18n.MsgAuthAccessTokenInvalid),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
// get header New-Api-User
|
||||
apiUserIdStr := c.Request.Header.Get("New-Api-User")
|
||||
if apiUserIdStr == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": common.TranslateMessage(c, i18n.MsgAuthUserIdNotProvided),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
apiUserId, err := strconv.Atoi(apiUserIdStr)
|
||||
user, identity, useAccessToken, err := authenticateDashboardRequest(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": common.TranslateMessage(c, i18n.MsgAuthUserIdFormatError),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
|
||||
}
|
||||
if id != apiUserId {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": common.TranslateMessage(c, i18n.MsgAuthUserIdMismatch),
|
||||
})
|
||||
c.Abort()
|
||||
writeDashboardAuthError(c, err)
|
||||
return
|
||||
}
|
||||
if status.(int) == common.UserStatusDisabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": common.TranslateMessage(c, i18n.MsgAuthUserBanned),
|
||||
})
|
||||
c.Abort()
|
||||
if user.Status != common.UserStatusEnabled {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "code": "AUTH_USER_DISABLED", "message": common.TranslateMessage(c, i18n.MsgAuthUserBanned)})
|
||||
return
|
||||
}
|
||||
if role.(int) < minRole {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": common.TranslateMessage(c, i18n.MsgAuthInsufficientPrivilege),
|
||||
})
|
||||
c.Abort()
|
||||
if user.Role < minRole {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"success": false, "code": "AUTH_INSUFFICIENT_PRIVILEGE", "message": common.TranslateMessage(c, i18n.MsgAuthInsufficientPrivilege)})
|
||||
return
|
||||
}
|
||||
if !validUserInfo(username.(string), role.(int)) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": common.TranslateMessage(c, i18n.MsgAuthUserInfoInvalid),
|
||||
})
|
||||
c.Abort()
|
||||
if !validUserInfo(user.Username, user.Role) {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "code": "AUTH_USER_INVALID", "message": common.TranslateMessage(c, i18n.MsgAuthUserInfoInvalid)})
|
||||
return
|
||||
}
|
||||
// 防止不同newapi版本冲突,导致数据不通用
|
||||
c.Header("Auth-Version", "864b7076dbcd0a3c01b5520316720ebf")
|
||||
c.Set("username", username)
|
||||
c.Set("role", role)
|
||||
c.Set("id", id)
|
||||
c.Set("group", session.Get("group"))
|
||||
c.Set("user_group", session.Get("group"))
|
||||
c.Set("use_access_token", useAccessToken)
|
||||
setDashboardAuthContext(c, user, identity, useAccessToken)
|
||||
|
||||
// 管理/root 写操作审计兜底:内聚在鉴权链路里,保证任何经过 AdminAuth/RootAuth
|
||||
// 的写接口都会自动留痕(无需在路由上单独挂审计中间件,避免漏挂)。
|
||||
@@ -169,10 +77,13 @@ func authHelper(c *gin.Context, minRole int) {
|
||||
|
||||
func TryUserAuth() func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
id := session.Get("id")
|
||||
if id != nil {
|
||||
c.Set("id", id)
|
||||
user, identity, credentialKind, err := classifyDashboardCredential(c)
|
||||
if err != nil {
|
||||
writeDashboardAuthError(c, err)
|
||||
return
|
||||
}
|
||||
if credentialKind != dashboardCredentialUnmatched {
|
||||
setDashboardAuthContext(c, user, identity, credentialKind == dashboardCredentialPAT)
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
@@ -196,6 +107,122 @@ func RootAuth() func(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// GetAuthIdentity returns a dashboard session identity. PAT-authenticated
|
||||
// requests intentionally have no SessionID and cannot manage browser sessions.
|
||||
func GetAuthIdentity(c *gin.Context) (service.AuthIdentity, bool) {
|
||||
value, ok := c.Get(authIdentityContextKey)
|
||||
if !ok {
|
||||
return service.AuthIdentity{}, false
|
||||
}
|
||||
identity, ok := value.(service.AuthIdentity)
|
||||
return identity, ok
|
||||
}
|
||||
|
||||
// GetSessionAuthIdentity returns only identities backed by a live dashboard
|
||||
// session. PAT-authenticated requests intentionally fail this check.
|
||||
func GetSessionAuthIdentity(c *gin.Context) (service.AuthIdentity, bool) {
|
||||
identity, ok := GetAuthIdentity(c)
|
||||
if !ok {
|
||||
identity = service.AuthIdentity{
|
||||
UserID: c.GetInt("id"),
|
||||
SessionID: c.GetString("session_id"),
|
||||
UserAuthVersion: c.GetInt64("auth_version"),
|
||||
SessionVersion: c.GetInt64("session_version"),
|
||||
}
|
||||
}
|
||||
if identity.UserID <= 0 || identity.SessionID == "" || identity.UserAuthVersion <= 0 || identity.SessionVersion <= 0 {
|
||||
return service.AuthIdentity{}, false
|
||||
}
|
||||
return identity, true
|
||||
}
|
||||
|
||||
func authenticateDashboardRequest(c *gin.Context) (*model.UserBase, service.AuthIdentity, bool, error) {
|
||||
user, identity, credentialKind, err := classifyDashboardCredential(c)
|
||||
if err != nil {
|
||||
return nil, service.AuthIdentity{}, credentialKind == dashboardCredentialPAT, err
|
||||
}
|
||||
if credentialKind == dashboardCredentialUnmatched {
|
||||
return nil, service.AuthIdentity{}, false, service.ErrAuthTokenInvalid
|
||||
}
|
||||
return user, identity, credentialKind == dashboardCredentialPAT, nil
|
||||
}
|
||||
|
||||
func classifyDashboardCredential(c *gin.Context) (*model.UserBase, service.AuthIdentity, dashboardCredentialKind, error) {
|
||||
raw, ok := authorizationToken(c.GetHeader("Authorization"))
|
||||
if !ok {
|
||||
return nil, service.AuthIdentity{}, dashboardCredentialUnmatched, nil
|
||||
}
|
||||
identity, internal, err := service.ParseDashboardAccessToken(raw)
|
||||
if internal {
|
||||
if err != nil {
|
||||
return nil, service.AuthIdentity{}, dashboardCredentialInternal, err
|
||||
}
|
||||
_, user, err := service.ValidateLoginSession(identity)
|
||||
if err != nil {
|
||||
return nil, service.AuthIdentity{}, dashboardCredentialInternal, err
|
||||
}
|
||||
return user, identity, dashboardCredentialInternal, nil
|
||||
}
|
||||
patUser, err := model.ValidateAccessToken(raw)
|
||||
if err != nil {
|
||||
return nil, service.AuthIdentity{}, dashboardCredentialPAT, err
|
||||
}
|
||||
if patUser == nil || patUser.Id <= 0 {
|
||||
return nil, service.AuthIdentity{}, dashboardCredentialUnmatched, nil
|
||||
}
|
||||
user, err := model.GetUserCache(patUser.Id)
|
||||
if err != nil {
|
||||
return nil, service.AuthIdentity{}, dashboardCredentialPAT, err
|
||||
}
|
||||
return user, service.AuthIdentity{UserID: user.Id, UserAuthVersion: user.AuthVersion}, dashboardCredentialPAT, nil
|
||||
}
|
||||
|
||||
func authorizationToken(header string) (string, bool) {
|
||||
header = strings.TrimSpace(header)
|
||||
if header == "" {
|
||||
return "", false
|
||||
}
|
||||
parts := strings.Fields(header)
|
||||
if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") {
|
||||
header = parts[1]
|
||||
} else if len(parts) != 1 {
|
||||
return "", false
|
||||
}
|
||||
return header, header != ""
|
||||
}
|
||||
|
||||
func setDashboardAuthContext(c *gin.Context, user *model.UserBase, identity service.AuthIdentity, useAccessToken bool) {
|
||||
c.Header("Auth-Version", "864b7076dbcd0a3c01b5520316720ebf")
|
||||
c.Set("username", user.Username)
|
||||
c.Set("role", user.Role)
|
||||
c.Set("id", user.Id)
|
||||
c.Set("group", user.Group)
|
||||
c.Set("user_group", user.Group)
|
||||
c.Set("use_access_token", useAccessToken)
|
||||
c.Set("session_id", identity.SessionID)
|
||||
c.Set("auth_version", identity.UserAuthVersion)
|
||||
c.Set("session_version", identity.SessionVersion)
|
||||
c.Set(authIdentityContextKey, identity)
|
||||
user.WriteContext(c)
|
||||
}
|
||||
|
||||
func writeDashboardAuthError(c *gin.Context, err error) {
|
||||
if errors.Is(err, service.ErrAuthTokenExpired) {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "code": "AUTH_TOKEN_EXPIRED", "message": common.TranslateMessage(c, i18n.MsgAuthNotLoggedIn)})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrLoginSessionRevoked) || errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "code": "AUTH_SESSION_REVOKED", "message": common.TranslateMessage(c, i18n.MsgAuthNotLoggedIn)})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrAuthTokenInvalid) {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "code": "AUTH_UNAUTHORIZED", "message": common.TranslateMessage(c, i18n.MsgAuthAccessTokenInvalid)})
|
||||
return
|
||||
}
|
||||
common.SysLog("dashboard authentication error: " + err.Error())
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"success": false, "code": "AUTH_INTERNAL_ERROR", "message": common.TranslateMessage(c, i18n.MsgDatabaseError)})
|
||||
}
|
||||
|
||||
func RequirePermission(permission authz.Permission) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
role := c.GetInt("role")
|
||||
@@ -220,16 +247,27 @@ func WssAuth(c *gin.Context) {
|
||||
// Used for endpoints that need to be accessible from both the dashboard and API clients.
|
||||
func TokenOrUserAuth() func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
// Try session auth first (dashboard users)
|
||||
session := sessions.Default(c)
|
||||
if id := session.Get("id"); id != nil {
|
||||
if status, ok := session.Get("status").(int); ok && status == common.UserStatusEnabled {
|
||||
c.Set("id", id)
|
||||
c.Next()
|
||||
raw, ok := authorizationToken(c.GetHeader("Authorization"))
|
||||
if ok {
|
||||
identity, internal, err := service.ParseDashboardAccessToken(raw)
|
||||
if !internal {
|
||||
TokenAuth()(c)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeDashboardAuthError(c, err)
|
||||
return
|
||||
}
|
||||
_, user, err := service.ValidateLoginSession(identity)
|
||||
if err != nil {
|
||||
writeDashboardAuthError(c, err)
|
||||
return
|
||||
}
|
||||
setDashboardAuthContext(c, user, identity, false)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
// Fall back to token auth (API clients)
|
||||
// Opaque credentials are relay API keys here, never dashboard PATs.
|
||||
TokenAuth()(c)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user