Files
new-api/controller/auth_session_test.go
CaIon 2906e4f779 fix(oauth): require account evidence before migrating legacy GitHub bindings
Legacy GitHub bindings stored the login name, while current bindings store
the numeric account ID. A legacy match no longer signs the user in on its
own. When the account has two-factor or a passkey, the binding is rewritten
only after that verification completes, inside the transaction that issues
the session. Otherwise it is rewritten when one of the GitHub account's
verified emails matches the account email. Without either, the login is
declined with guidance to sign in another way and relink GitHub in account
settings. All-digit login names never take part in the comparison, the bind
flow no longer compares login names, and every migration or declined
attempt is recorded as an account binding audit event.
2026-09-18 23:00:37 +08:00

156 lines
5.4 KiB
Go

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.TwoFA{}, &model.PasskeyCredential{}))
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, nil, 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)
}