mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 20:54:14 +00:00
When the admin changes username/password from one machine, sessions on every other machine kept working until they manually logged out because session storage is a signed client-side cookie — there is no server-side session list to revoke. Add a per-user LoginEpoch counter stamped into the session at login and re-verified on every authenticated request. UpdateUser and UpdateFirstUser bump the epoch (UpdateUser via gorm.Expr so a single update statement is atomic), so any cookie issued before the change no longer matches the user's current epoch and GetLoginUser returns nil — the SPA's 401 interceptor then redirects to the login page. Backward compatible: the column defaults to 0 and missing cookie values are treated as 0, so sessions issued before this change remain valid until the first credential update.
193 lines
3.8 KiB
Go
193 lines
3.8 KiB
Go
package session
|
|
|
|
import (
|
|
"encoding/gob"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/database"
|
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/logger"
|
|
|
|
"github.com/gin-contrib/sessions"
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
const (
|
|
loginUserKey = "LOGIN_USER"
|
|
loginEpochKey = "LOGIN_EPOCH"
|
|
apiAuthUserKey = "api_auth_user"
|
|
sessionCookieName = "3x-ui"
|
|
)
|
|
|
|
func init() {
|
|
gob.Register(model.User{})
|
|
}
|
|
|
|
func SetLoginUser(c *gin.Context, user *model.User) error {
|
|
if user == nil {
|
|
return nil
|
|
}
|
|
s := sessions.Default(c)
|
|
s.Set(loginUserKey, user.Id)
|
|
s.Set(loginEpochKey, user.LoginEpoch)
|
|
return s.Save()
|
|
}
|
|
|
|
func SetAPIAuthUser(c *gin.Context, user *model.User) {
|
|
if user == nil {
|
|
return
|
|
}
|
|
c.Set(apiAuthUserKey, user)
|
|
}
|
|
|
|
func GetLoginUser(c *gin.Context) *model.User {
|
|
if v, ok := c.Get(apiAuthUserKey); ok {
|
|
if u, ok2 := v.(*model.User); ok2 {
|
|
return u
|
|
}
|
|
}
|
|
s := sessions.Default(c)
|
|
obj := s.Get(loginUserKey)
|
|
if obj == nil {
|
|
return nil
|
|
}
|
|
userID, ok := sessionUserID(obj)
|
|
if !ok {
|
|
s.Delete(loginUserKey)
|
|
s.Delete(loginEpochKey)
|
|
if err := s.Save(); err != nil {
|
|
logger.Warning("session: failed to drop stale user payload:", err)
|
|
}
|
|
return nil
|
|
}
|
|
if legacyUserID, ok := legacySessionUserID(obj); ok {
|
|
s.Set(loginUserKey, legacyUserID)
|
|
if err := s.Save(); err != nil {
|
|
logger.Warning("session: failed to migrate legacy user payload:", err)
|
|
}
|
|
}
|
|
user, err := getUserByID(userID)
|
|
if err != nil {
|
|
logger.Warning("session: failed to load user:", err)
|
|
s.Delete(loginUserKey)
|
|
s.Delete(loginEpochKey)
|
|
if saveErr := s.Save(); saveErr != nil {
|
|
logger.Warning("session: failed to drop missing user:", saveErr)
|
|
}
|
|
return nil
|
|
}
|
|
if !sessionEpochMatches(s.Get(loginEpochKey), user.LoginEpoch) {
|
|
s.Delete(loginUserKey)
|
|
s.Delete(loginEpochKey)
|
|
if saveErr := s.Save(); saveErr != nil {
|
|
logger.Warning("session: failed to drop stale epoch:", saveErr)
|
|
}
|
|
return nil
|
|
}
|
|
return user
|
|
}
|
|
|
|
func sessionEpochMatches(cookieVal any, userEpoch int64) bool {
|
|
var got int64
|
|
switch v := cookieVal.(type) {
|
|
case nil:
|
|
case int64:
|
|
got = v
|
|
case int:
|
|
got = int64(v)
|
|
case int32:
|
|
got = int64(v)
|
|
case float64:
|
|
got = int64(v)
|
|
default:
|
|
return false
|
|
}
|
|
return got == userEpoch
|
|
}
|
|
|
|
func IsLogin(c *gin.Context) bool {
|
|
return GetLoginUser(c) != nil
|
|
}
|
|
|
|
func sessionUserID(obj any) (int, bool) {
|
|
switch v := obj.(type) {
|
|
case int:
|
|
return v, v > 0
|
|
case int64:
|
|
return int(v), v > 0
|
|
case int32:
|
|
return int(v), v > 0
|
|
case float64:
|
|
id := int(v)
|
|
return id, v == float64(id) && id > 0
|
|
case model.User:
|
|
return v.Id, v.Id > 0
|
|
case *model.User:
|
|
if v == nil {
|
|
return 0, false
|
|
}
|
|
return v.Id, v.Id > 0
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
|
|
func legacySessionUserID(obj any) (int, bool) {
|
|
switch v := obj.(type) {
|
|
case model.User:
|
|
return v.Id, v.Id > 0
|
|
case *model.User:
|
|
if v == nil {
|
|
return 0, false
|
|
}
|
|
return v.Id, v.Id > 0
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
|
|
func getUserByID(id int) (*model.User, error) {
|
|
db := database.GetDB()
|
|
if db == nil {
|
|
return nil, http.ErrServerClosed
|
|
}
|
|
user := &model.User{}
|
|
if err := db.Model(model.User{}).Where("id = ?", id).First(user).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
func ClearSession(c *gin.Context) error {
|
|
s := sessions.Default(c)
|
|
s.Clear()
|
|
cookiePath := c.GetString("base_path")
|
|
if cookiePath == "" {
|
|
cookiePath = "/"
|
|
}
|
|
secure := c.Request.TLS != nil
|
|
s.Options(sessions.Options{
|
|
Path: cookiePath,
|
|
MaxAge: -1,
|
|
HttpOnly: true,
|
|
Secure: secure,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
if err := s.Save(); err != nil {
|
|
return err
|
|
}
|
|
if cookiePath != "/" {
|
|
http.SetCookie(c.Writer, &http.Cookie{
|
|
Name: sessionCookieName,
|
|
Value: "",
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
Expires: time.Unix(0, 0),
|
|
HttpOnly: true,
|
|
Secure: secure,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|
|
return nil
|
|
}
|