fix(auth): invalidate other sessions when credentials change

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.
This commit is contained in:
MHSanaei 2026-05-13 12:48:13 +02:00
parent f69cdd3841
commit 42e2a91b92
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
3 changed files with 43 additions and 16 deletions

View file

@ -21,12 +21,8 @@ const (
Shadowsocks Protocol = "shadowsocks" Shadowsocks Protocol = "shadowsocks"
Mixed Protocol = "mixed" Mixed Protocol = "mixed"
WireGuard Protocol = "wireguard" WireGuard Protocol = "wireguard"
// UI stores Hysteria v1 and v2 both as "hysteria" and uses Hysteria Protocol = "hysteria"
// settings.version to discriminate. Imports from outside the panel Hysteria2 Protocol = "hysteria2"
// can carry the literal "hysteria2" string, so IsHysteria below
// accepts both.
Hysteria Protocol = "hysteria"
Hysteria2 Protocol = "hysteria2"
) )
// IsHysteria returns true for both "hysteria" and "hysteria2". // IsHysteria returns true for both "hysteria" and "hysteria2".
@ -38,9 +34,10 @@ func IsHysteria(p Protocol) bool {
// User represents a user account in the 3x-ui panel. // User represents a user account in the 3x-ui panel.
type User struct { type User struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"` Id int `json:"id" gorm:"primaryKey;autoIncrement"`
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
LoginEpoch int64 `json:"-" gorm:"default:0"`
} }
// Inbound represents an Xray inbound configuration with traffic statistics and settings. // Inbound represents an Xray inbound configuration with traffic statistics and settings.
@ -66,12 +63,7 @@ type Inbound struct {
StreamSettings string `json:"streamSettings" form:"streamSettings"` StreamSettings string `json:"streamSettings" form:"streamSettings"`
Tag string `json:"tag" form:"tag" gorm:"unique"` Tag string `json:"tag" form:"tag" gorm:"unique"`
Sniffing string `json:"sniffing" form:"sniffing"` Sniffing string `json:"sniffing" form:"sniffing"`
NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
// NodeID points at the remote panel (Node) where this inbound's xray
// actually runs. NULL means the inbound runs on the local xray (the
// pre-multi-node behaviour). Existing rows migrate to NULL with no
// backfill.
NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
} }
// OutboundTraffics tracks traffic statistics for Xray outbound connections. // OutboundTraffics tracks traffic statistics for Xray outbound connections.

View file

@ -122,7 +122,11 @@ func (s *UserService) UpdateUser(id int, username string, password string) error
return db.Model(model.User{}). return db.Model(model.User{}).
Where("id = ?", id). Where("id = ?", id).
Updates(map[string]any{"username": username, "password": hashedPassword}). Updates(map[string]any{
"username": username,
"password": hashedPassword,
"login_epoch": gorm.Expr("login_epoch + 1"),
}).
Error Error
} }
@ -150,5 +154,6 @@ func (s *UserService) UpdateFirstUser(username string, password string) error {
} }
user.Username = username user.Username = username
user.Password = hashedPassword user.Password = hashedPassword
user.LoginEpoch++
return db.Save(user).Error return db.Save(user).Error
} }

View file

@ -15,6 +15,7 @@ import (
const ( const (
loginUserKey = "LOGIN_USER" loginUserKey = "LOGIN_USER"
loginEpochKey = "LOGIN_EPOCH"
apiAuthUserKey = "api_auth_user" apiAuthUserKey = "api_auth_user"
sessionCookieName = "3x-ui" sessionCookieName = "3x-ui"
) )
@ -29,6 +30,7 @@ func SetLoginUser(c *gin.Context, user *model.User) error {
} }
s := sessions.Default(c) s := sessions.Default(c)
s.Set(loginUserKey, user.Id) s.Set(loginUserKey, user.Id)
s.Set(loginEpochKey, user.LoginEpoch)
return s.Save() return s.Save()
} }
@ -53,6 +55,7 @@ func GetLoginUser(c *gin.Context) *model.User {
userID, ok := sessionUserID(obj) userID, ok := sessionUserID(obj)
if !ok { if !ok {
s.Delete(loginUserKey) s.Delete(loginUserKey)
s.Delete(loginEpochKey)
if err := s.Save(); err != nil { if err := s.Save(); err != nil {
logger.Warning("session: failed to drop stale user payload:", err) logger.Warning("session: failed to drop stale user payload:", err)
} }
@ -68,14 +71,41 @@ func GetLoginUser(c *gin.Context) *model.User {
if err != nil { if err != nil {
logger.Warning("session: failed to load user:", err) logger.Warning("session: failed to load user:", err)
s.Delete(loginUserKey) s.Delete(loginUserKey)
s.Delete(loginEpochKey)
if saveErr := s.Save(); saveErr != nil { if saveErr := s.Save(); saveErr != nil {
logger.Warning("session: failed to drop missing user:", saveErr) logger.Warning("session: failed to drop missing user:", saveErr)
} }
return nil 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 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 { func IsLogin(c *gin.Context) bool {
return GetLoginUser(c) != nil return GetLoginUser(c) != nil
} }