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:
@@ -0,0 +1,155 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestAuthLogoutRejectsRefreshCookieSessionMismatch(t *testing.T) {
|
||||
previousDB := model.DB
|
||||
previousRedis := common.RedisEnabled
|
||||
previousSecret := common.SessionSecret
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}))
|
||||
model.DB = db
|
||||
common.RedisEnabled = false
|
||||
common.SessionSecret = "auth-logout-mismatch-test-secret"
|
||||
t.Cleanup(func() {
|
||||
model.DB = previousDB
|
||||
common.RedisEnabled = previousRedis
|
||||
common.SessionSecret = previousSecret
|
||||
})
|
||||
|
||||
user := &model.User{
|
||||
Username: "logout-mismatch-user", Password: "unused", Role: common.RoleCommonUser,
|
||||
Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1,
|
||||
}
|
||||
require.NoError(t, db.Create(user).Error)
|
||||
sessionA, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "agent-a")
|
||||
require.NoError(t, err)
|
||||
sessionB, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "agent-b")
|
||||
require.NoError(t, err)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/api/user/auth/logout", nil)
|
||||
c.Request.Header.Set("Authorization", "Bearer "+sessionA.AccessToken)
|
||||
c.Request.Header.Set("X-Auth-Session", sessionA.Session.SID)
|
||||
c.Request.AddCookie(&http.Cookie{Name: service.RefreshCookieName, Value: sessionB.RefreshToken})
|
||||
|
||||
AuthLogout(c)
|
||||
|
||||
assert.Equal(t, http.StatusConflict, recorder.Code)
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
|
||||
assert.False(t, response.Success)
|
||||
assert.Equal(t, "AUTH_SESSION_MISMATCH", response.Code)
|
||||
for _, sid := range []string{sessionA.Session.SID, sessionB.Session.SID} {
|
||||
stored, err := model.GetUserSessionBySID(sid)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, model.UserSessionStatusActive, stored.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteAuthSessionErrorMapsSessionGrowthLimits(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
expectedStatus int
|
||||
expectedCode string
|
||||
}{
|
||||
{
|
||||
name: "active session limit",
|
||||
err: model.ErrUserSessionLimit,
|
||||
expectedStatus: http.StatusConflict,
|
||||
expectedCode: "AUTH_SESSION_LIMIT",
|
||||
},
|
||||
{
|
||||
name: "issuance limit",
|
||||
err: model.ErrUserSessionIssuanceLimit,
|
||||
expectedStatus: http.StatusTooManyRequests,
|
||||
expectedCode: "AUTH_SESSION_ISSUANCE_LIMIT",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
writeAuthSessionError(c, test.err)
|
||||
|
||||
assert.Equal(t, test.expectedStatus, recorder.Code)
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
|
||||
assert.False(t, response.Success)
|
||||
assert.Equal(t, test.expectedCode, response.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionLimitDoesNotRecordRejectedLoginAsSuccessful(t *testing.T) {
|
||||
previousDB := model.DB
|
||||
previousRedis := common.RedisEnabled
|
||||
previousActiveLimit := common.UserSessionActiveLimit
|
||||
previousIssuanceLimit := common.UserSessionIssuanceLimit
|
||||
previousIssuanceWindow := common.UserSessionIssuanceWindowSeconds
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}))
|
||||
model.DB = db
|
||||
common.RedisEnabled = false
|
||||
common.UserSessionActiveLimit = 1
|
||||
common.UserSessionIssuanceLimit = 100
|
||||
common.UserSessionIssuanceWindowSeconds = int64(common.DefaultUserSessionIssuanceWindowSeconds)
|
||||
t.Cleanup(func() {
|
||||
model.DB = previousDB
|
||||
common.RedisEnabled = previousRedis
|
||||
common.UserSessionActiveLimit = previousActiveLimit
|
||||
common.UserSessionIssuanceLimit = previousIssuanceLimit
|
||||
common.UserSessionIssuanceWindowSeconds = previousIssuanceWindow
|
||||
})
|
||||
|
||||
const previousLastLoginAt = int64(123)
|
||||
user := &model.User{
|
||||
Username: "rejected-login-audit-user", Password: "unused", Role: common.RoleCommonUser,
|
||||
Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, LastLoginAt: previousLastLoginAt,
|
||||
}
|
||||
require.NoError(t, db.Create(user).Error)
|
||||
now := time.Now().Unix()
|
||||
require.NoError(t, db.Create(&model.UserSession{
|
||||
SID: "existing-active-session", UserID: user.Id, Version: 1, UserAuthVersion: user.AuthVersion,
|
||||
Status: model.UserSessionStatusActive, RefreshHash: "hash", LoginMethod: "password",
|
||||
CreatedAt: now, LastActiveAt: now, ExpiresAt: now + 3600,
|
||||
}).Error)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/api/user/login", nil)
|
||||
setupLogin(user, c)
|
||||
|
||||
assert.Equal(t, http.StatusConflict, recorder.Code)
|
||||
var stored model.User
|
||||
require.NoError(t, db.First(&stored, user.Id).Error)
|
||||
assert.Equal(t, previousLastLoginAt, stored.LastLoginAt)
|
||||
}
|
||||
Reference in New Issue
Block a user