mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 13:14:11 +00:00
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:
parent
f69cdd3841
commit
42e2a91b92
3 changed files with 43 additions and 16 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue