mirror of
https://gh-proxy.org/https://github.com/klinsmaya/new-api.git
synced 2026-11-09 01:47:07 +08:00
Treat TOTP and Passkey as alternative enrolled factors across login and sensitive account operations. Gate every primary login transport before issuing a session, require WebAuthn user verification, and consume login challenges atomically with session creation. Reuse the shared verification UI for login, 2FA management, and account deletion. Require scoped, single-use deletion proof; recheck the session inside the deletion transaction and revoke all sessions afterward. Validation: controller/service/model/middleware tests; real SQLite 3.50.4, MySQL 8.4.11, and PostgreSQL 16.15 security regressions; frontend tests, TypeScript, targeted lint, formatting, and production build. Deploy the frontend and all backend nodes together. No schema changes.
156 lines
5.3 KiB
Go
156 lines
5.3 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, 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)
|
|
}
|