From c1d560fd4a8fbbbfb441a0ce4b9e1cfb06040fb0 Mon Sep 17 00:00:00 2001 From: kevin Date: Fri, 3 Jul 2026 10:52:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=9A=E8=AF=9D=E7=AE=A1=E7=90=86=20?= =?UTF-8?q?-=20=E6=94=AF=E6=8C=81=E6=8C=89=E8=AE=BE=E5=A4=87=E7=B2=92?= =?UTF-8?q?=E5=BA=A6=E8=B8=A2=E4=B8=8B=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 sessions 表,每次登录创建会话记录(记录 IP/UA) - AuthMiddleware 改为基于 session 校验,可精确踢掉单个设备 - 自己改密码后踢掉其他设备,管理员改密码后踢掉该用户全部设备 - 新增 GET /api/me/sessions 查看活跃会话列表 - 新增 DELETE /api/me/sessions/:sessionId 踢掉指定会话 - 新增 DELETE /api/admin/users/:id/sessions 管理员强制下线某用户 - 兼容无 session_id 的旧 token,回退到 TokenInvalidBefore 校验 --- internal/db/db.go | 2 +- internal/handler/auth.go | 26 ++++++++++- internal/handler/session.go | 88 +++++++++++++++++++++++++++++++++++++ internal/handler/user.go | 6 +++ internal/middleware/auth.go | 58 +++++++++++++++++++++--- internal/model/session.go | 18 ++++++++ internal/model/user.go | 15 ++++--- internal/router/router.go | 3 ++ 8 files changed, 199 insertions(+), 17 deletions(-) create mode 100644 internal/handler/session.go create mode 100644 internal/model/session.go diff --git a/internal/db/db.go b/internal/db/db.go index a41dffa..4350346 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -36,7 +36,7 @@ func Init(cfg *config.DatabaseConfig) error { return fmt.Errorf("数据库连接失败: %w", err) } - if err := DB.AutoMigrate(&model.User{}); err != nil { + if err := DB.AutoMigrate(&model.User{}, &model.Session{}); err != nil { return fmt.Errorf("数据库迁移失败: %w", err) } diff --git a/internal/handler/auth.go b/internal/handler/auth.go index 0ebf178..e75bca4 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -2,12 +2,14 @@ package handler import ( "net/http" + "time" "lmvpn/internal/db" "lmvpn/internal/middleware" "lmvpn/internal/model" "github.com/gin-gonic/gin" + "github.com/google/uuid" "golang.org/x/crypto/bcrypt" ) @@ -50,12 +52,25 @@ func Login(c *gin.Context) { return } - token, err := middleware.GenerateToken(user.ID, user.Username, user.Role) + sessionID := uuid.New().String() + token, err := middleware.GenerateToken(sessionID, user.ID, user.Username, user.Role) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "生成令牌失败"}) return } + session := model.Session{ + SessionID: sessionID, + UserID: user.ID, + IP: c.ClientIP(), + UserAgent: c.GetHeader("User-Agent"), + ExpiresAt: time.Now().Add(24 * time.Hour), + } + if err := db.DB.Create(&session).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "创建会话失败"}) + return + } + c.JSON(http.StatusOK, loginResponse{ Token: token, User: userInfo{ @@ -102,11 +117,18 @@ func ChangePassword(c *gin.Context) { return } - if err := db.DB.Model(&user).Update("password", string(hash)).Error; err != nil { + now := time.Now() + if err := db.DB.Model(&user).Updates(map[string]interface{}{ + "password": string(hash), + "token_invalid_before": now, + }).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "密码修改失败"}) return } + sessionID, _ := c.Get("session_id") + db.DB.Model(&model.Session{}).Where("user_id = ? AND session_id != ?", userID, sessionID).Update("invalid", true) + c.JSON(http.StatusOK, gin.H{"message": "密码修改成功"}) } diff --git a/internal/handler/session.go b/internal/handler/session.go new file mode 100644 index 0000000..6da731a --- /dev/null +++ b/internal/handler/session.go @@ -0,0 +1,88 @@ +package handler + +import ( + "net/http" + "strconv" + "time" + + "lmvpn/internal/db" + "lmvpn/internal/model" + + "github.com/gin-gonic/gin" +) + +type sessionResponse struct { + SessionID string `json:"session_id"` + IP string `json:"ip"` + UserAgent string `json:"user_agent"` + Current bool `json:"current"` + CreatedAt string `json:"created_at"` + ExpiresAt string `json:"expires_at"` +} + +func ListMySessions(c *gin.Context) { + userID, _ := c.Get("user_id") + currentSessionID, _ := c.Get("session_id") + + var sessions []model.Session + db.DB.Where("user_id = ? AND invalid = ? AND expires_at > ?", userID, false, time.Now()). + Order("created_at desc"). + Find(&sessions) + + result := make([]sessionResponse, len(sessions)) + for i, s := range sessions { + result[i] = sessionResponse{ + SessionID: s.SessionID, + IP: s.IP, + UserAgent: s.UserAgent, + Current: s.SessionID == currentSessionID, + CreatedAt: s.CreatedAt.Format("2006-01-02 15:04:05"), + ExpiresAt: s.ExpiresAt.Format("2006-01-02 15:04:05"), + } + } + + c.JSON(http.StatusOK, gin.H{"sessions": result}) +} + +func RevokeMySession(c *gin.Context) { + sessionID := c.Param("sessionId") + if sessionID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) + return + } + + userID, _ := c.Get("user_id") + currentSessionID, _ := c.Get("session_id") + + if sessionID == currentSessionID { + c.JSON(http.StatusBadRequest, gin.H{"error": "不能撤销当前会话"}) + return + } + + var session model.Session + if err := db.DB.Where("session_id = ? AND user_id = ?", sessionID, userID).First(&session).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "会话不存在"}) + return + } + + db.DB.Model(&session).Update("invalid", true) + c.JSON(http.StatusOK, gin.H{"message": "已撤销会话"}) +} + +func AdminRevokeUserSessions(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) + return + } + + var user model.User + if err := db.DB.First(&user, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"}) + return + } + + db.DB.Model(&model.Session{}).Where("user_id = ?", id).Update("invalid", true) + c.JSON(http.StatusOK, gin.H{"message": "已强制下线该用户"}) +} diff --git a/internal/handler/user.go b/internal/handler/user.go index c3e3a2c..1585d06 100644 --- a/internal/handler/user.go +++ b/internal/handler/user.go @@ -3,6 +3,7 @@ package handler import ( "net/http" "strconv" + "time" "lmvpn/internal/db" "lmvpn/internal/model" @@ -153,6 +154,7 @@ func UpdateUser(c *gin.Context) { return } updates["password"] = string(hash) + updates["token_invalid_before"] = time.Now() } if len(updates) == 0 { @@ -165,6 +167,10 @@ func UpdateUser(c *gin.Context) { return } + if req.Password != "" { + db.DB.Model(&model.Session{}).Where("user_id = ?", id).Update("invalid", true) + } + db.DB.First(&user, id) c.JSON(http.StatusOK, formatUser(&user)) } diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 5833e1e..58ba6c1 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -5,6 +5,9 @@ import ( "strings" "time" + "lmvpn/internal/db" + "lmvpn/internal/model" + "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" ) @@ -15,17 +18,19 @@ const ( ) type Claims struct { - UserID uint `json:"user_id"` - Username string `json:"username"` - Role string `json:"role"` + SessionID string `json:"session_id,omitempty"` + UserID uint `json:"user_id"` + Username string `json:"username"` + Role string `json:"role"` jwt.RegisteredClaims } -func GenerateToken(userID uint, username, role string) (string, error) { +func GenerateToken(sessionID string, userID uint, username, role string) (string, error) { claims := Claims{ - UserID: userID, - Username: username, - Role: role, + SessionID: sessionID, + UserID: userID, + Username: username, + Role: role, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(tokenExpire)), IssuedAt: jwt.NewNumericDate(time.Now()), @@ -86,9 +91,48 @@ func AuthMiddleware() gin.HandlerFunc { return } + var user model.User + if err := db.DB.First(&user, claims.UserID).Error; err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "用户不存在"}) + c.Abort() + return + } + + if user.Status != 1 { + c.JSON(http.StatusForbidden, gin.H{"error": "账号已被禁用"}) + c.Abort() + return + } + + if claims.SessionID != "" { + var session model.Session + if err := db.DB.Where("session_id = ?", claims.SessionID).First(&session).Error; err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "会话不存在"}) + c.Abort() + return + } + if session.Invalid { + c.JSON(http.StatusUnauthorized, gin.H{"error": "会话已失效,请重新登录"}) + c.Abort() + return + } + if time.Now().After(session.ExpiresAt) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "会话已过期,请重新登录"}) + c.Abort() + return + } + } else if user.TokenInvalidBefore != nil && claims.IssuedAt != nil { + if claims.IssuedAt.Time.Before(*user.TokenInvalidBefore) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "令牌已失效,请重新登录"}) + c.Abort() + return + } + } + c.Set("user_id", claims.UserID) c.Set("username", claims.Username) c.Set("role", claims.Role) + c.Set("session_id", claims.SessionID) c.Next() } } diff --git a/internal/model/session.go b/internal/model/session.go new file mode 100644 index 0000000..0e6b8d2 --- /dev/null +++ b/internal/model/session.go @@ -0,0 +1,18 @@ +package model + +import "time" + +type Session struct { + ID uint `gorm:"primaryKey;autoIncrement"` + SessionID string `gorm:"uniqueIndex;size:36;not null"` + UserID uint `gorm:"index;not null"` + IP string `gorm:"size:64"` + UserAgent string `gorm:"size:512"` + Invalid bool `gorm:"default:false"` + CreatedAt time.Time `gorm:"autoCreateTime"` + ExpiresAt time.Time `gorm:"not null"` +} + +func (Session) TableName() string { + return "sessions" +} diff --git a/internal/model/user.go b/internal/model/user.go index 488f2a5..e23dfdd 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -3,13 +3,14 @@ package model import "time" type User struct { - ID uint `gorm:"primaryKey;autoIncrement"` - Username string `gorm:"uniqueIndex;size:64;not null"` - Password string `gorm:"size:128;not null"` - Role string `gorm:"size:16;default:user"` - Status int `gorm:"default:1"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` + ID uint `gorm:"primaryKey;autoIncrement"` + Username string `gorm:"uniqueIndex;size:64;not null"` + Password string `gorm:"size:128;not null"` + Role string `gorm:"size:16;default:user"` + Status int `gorm:"default:1"` + TokenInvalidBefore *time.Time `gorm:"default:null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` } func (User) TableName() string { diff --git a/internal/router/router.go b/internal/router/router.go index e0c96fb..d3cb7f3 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -22,6 +22,8 @@ func Setup(r *gin.Engine) { { auth.GET("/me", handler.Me) auth.PUT("/me/password", handler.ChangePassword) + auth.GET("/me/sessions", handler.ListMySessions) + auth.DELETE("/me/sessions/:sessionId", handler.RevokeMySession) } admin := r.Group("/api/admin") @@ -32,6 +34,7 @@ func Setup(r *gin.Engine) { admin.POST("/users", handler.CreateUser) admin.PUT("/users/:id", handler.UpdateUser) admin.DELETE("/users/:id", handler.DeleteUser) + admin.DELETE("/users/:id/sessions", handler.AdminRevokeUserSessions) } fs := http.FileServer(http.Dir("./dist"))