diff --git a/internal/controller/auth_api_login.go b/internal/controller/auth_api_login.go index 7a3ce0f..53bcf6b 100644 --- a/internal/controller/auth_api_login.go +++ b/internal/controller/auth_api_login.go @@ -81,7 +81,7 @@ func (ac *AuthController) Login(c *gin.Context) { ac.rateLimiter.Clear(email, ip) // 创建服务端 session - sid, err := ac.sessionManager.Create(user, req.RememberMe) + sid, err := ac.sessionManager.Create(user, req.RememberMe, ip, c.GetHeader("User-Agent")) if err != nil { common.Error(c, http.StatusInternalServerError, "登录失败,请稍后重试") return @@ -111,7 +111,7 @@ func (ac *AuthController) ConfirmRestore(c *gin.Context) { return } - sid, err := ac.sessionManager.Create(user, req.RememberMe) + sid, err := ac.sessionManager.Create(user, req.RememberMe, clientIP(c), c.GetHeader("User-Agent")) if err != nil { common.Error(c, http.StatusInternalServerError, "操作失败,请稍后重试") return diff --git a/internal/controller/auth_api_register.go b/internal/controller/auth_api_register.go index bed4c8b..87d48cc 100644 --- a/internal/controller/auth_api_register.go +++ b/internal/controller/auth_api_register.go @@ -61,7 +61,7 @@ func (ac *AuthController) Register(c *gin.Context) { return } - sid, err := ac.sessionManager.Create(user, req.RememberMe) + sid, err := ac.sessionManager.Create(user, req.RememberMe, clientIP(c), c.GetHeader("User-Agent")) if err != nil { common.Error(c, http.StatusInternalServerError, "注册失败,请稍后重试") return diff --git a/internal/controller/interfaces.go b/internal/controller/interfaces.go index aa92037..72d82d9 100644 --- a/internal/controller/interfaces.go +++ b/internal/controller/interfaces.go @@ -4,6 +4,7 @@ import ( "metazone.cc/metalab/internal/middleware" "metazone.cc/metalab/internal/model" "metazone.cc/metalab/internal/service" + "metazone.cc/metalab/internal/session" ) // authUseCase AuthController 对 AuthService 的最小依赖(ISP:5 个方法) @@ -51,3 +52,11 @@ type spaceUseCase interface { GetSpaceUser(uid uint) (*model.User, error) GetPostsByUser(uid uint, page, pageSize int) ([]model.Post, int64, error) } + +// sessionManager 登录管理对会话管理的最小依赖(ISP:4 个方法) +type sessionManager interface { + ListByUID(uid uint) ([]*session.Session, error) + Destroy(sid string) error + DestroyOtherByUID(uid uint, currentSID string) error + UpdateRemark(sid string, uid uint, remark string) error +} diff --git a/internal/controller/settings_api_sessions.go b/internal/controller/settings_api_sessions.go new file mode 100644 index 0000000..92a691d --- /dev/null +++ b/internal/controller/settings_api_sessions.go @@ -0,0 +1,161 @@ +package controller + +import ( + "net/http" + + "metazone.cc/metalab/internal/common" + + "github.com/gin-gonic/gin" +) + +// ListSessions 列出当前用户的所有活跃会话 +func (sc *SettingsController) ListSessions(c *gin.Context) { + uid, exists := c.Get("uid") + if !exists { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + sessions, err := sc.sessionMgr.ListByUID(uid.(uint)) + if err != nil { + common.Error(c, http.StatusInternalServerError, "查询失败") + return + } + + currentSID, _ := c.Cookie(common.SessionCookieName) + + type sessionItem struct { + ID string `json:"id"` + IP string `json:"ip"` + UserAgent string `json:"user_agent"` + Remark string `json:"remark"` + RememberMe bool `json:"remember_me"` + CreatedAt string `json:"created_at"` + LastAccess string `json:"last_access"` + IsCurrent bool `json:"is_current"` + } + + var items []sessionItem + for _, s := range sessions { + items = append(items, sessionItem{ + ID: s.ID, + IP: s.IP, + UserAgent: s.UserAgent, + Remark: s.Remark, + RememberMe: s.RememberMe, + CreatedAt: s.CreatedAt.Format("2006-01-02 15:04:05"), + LastAccess: s.LastAccess.Format("2006-01-02 15:04:05"), + IsCurrent: s.ID == currentSID, + }) + } + if items == nil { + items = []sessionItem{} + } + + common.Ok(c, gin.H{"sessions": items}) +} + +// DestroySession 踢出指定会话(需验证归属当前用户) +func (sc *SettingsController) DestroySession(c *gin.Context) { + uid, exists := c.Get("uid") + if !exists { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + sid := c.Param("sid") + if sid == "" { + common.Error(c, http.StatusBadRequest, "缺少会话 ID") + return + } + + // 验证目标会话属于当前用户 + sessions, err := sc.sessionMgr.ListByUID(uid.(uint)) + if err != nil { + common.Error(c, http.StatusInternalServerError, "操作失败") + return + } + found := false + for _, s := range sessions { + if s.ID == sid { + found = true + break + } + } + if !found { + common.Error(c, http.StatusNotFound, "会话不存在") + return + } + + // 不能踢出当前会话 + currentSID, _ := c.Cookie(common.SessionCookieName) + if sid == currentSID { + common.Error(c, http.StatusBadRequest, "不能踢出当前设备,请使用退出登录") + return + } + + if err := sc.sessionMgr.Destroy(sid); err != nil { + common.Error(c, http.StatusInternalServerError, "操作失败") + return + } + + common.OkMessage(c, "已踢出该设备") +} + +// DestroyOtherSessions 一键踢出非当前设备的所有会话 +func (sc *SettingsController) DestroyOtherSessions(c *gin.Context) { + uid, exists := c.Get("uid") + if !exists { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + currentSID, _ := c.Cookie(common.SessionCookieName) + if currentSID == "" { + common.Error(c, http.StatusBadRequest, "无法识别当前会话") + return + } + + if err := sc.sessionMgr.DestroyOtherByUID(uid.(uint), currentSID); err != nil { + common.Error(c, http.StatusInternalServerError, "操作失败") + return + } + + common.OkMessage(c, "已踢出所有其他设备") +} + +// UpdateSessionRemark 更新指定会话的备注 +func (sc *SettingsController) UpdateSessionRemark(c *gin.Context) { + uid, exists := c.Get("uid") + if !exists { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + sid := c.Param("sid") + if sid == "" { + common.Error(c, http.StatusBadRequest, "缺少会话 ID") + return + } + + var req struct { + Remark string `json:"remark"` + } + if err := c.ShouldBindJSON(&req); err != nil { + common.Error(c, http.StatusBadRequest, "请检查输入") + return + } + + // 备注长度限制 + if len(req.Remark) > 32 { + common.Error(c, http.StatusBadRequest, "备注不能超过 32 个字符") + return + } + + if err := sc.sessionMgr.UpdateRemark(sid, uid.(uint), req.Remark); err != nil { + common.Error(c, http.StatusInternalServerError, "操作失败") + return + } + + common.OkMessage(c, "备注已更新") +} diff --git a/internal/controller/settings_controller.go b/internal/controller/settings_controller.go index 9e762e6..f87d2e4 100644 --- a/internal/controller/settings_controller.go +++ b/internal/controller/settings_controller.go @@ -6,6 +6,7 @@ import ( "metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/model" + "metazone.cc/metalab/internal/session" "github.com/gin-gonic/gin" ) @@ -37,11 +38,12 @@ type SettingsController struct { authService profileProvider avatarService avatarProvider auditService auditSubmittable + sessionMgr sessionManager } // NewSettingsController 构造函数 -func NewSettingsController(authService profileProvider, avatarService avatarProvider, auditService auditSubmittable) *SettingsController { - return &SettingsController{authService: authService, avatarService: avatarService, auditService: auditService} +func NewSettingsController(authService profileProvider, avatarService avatarProvider, auditService auditSubmittable, sessionMgr sessionManager) *SettingsController { + return &SettingsController{authService: authService, avatarService: avatarService, auditService: auditService, sessionMgr: sessionMgr} } // SettingsPage 个人设置页面(需登录) @@ -55,7 +57,7 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) { uid := uidVal.(uint) tab := c.Param("tab") - if tab != "profile" && tab != "account" { + if tab != "profile" && tab != "account" && tab != "sessions" { c.String(http.StatusNotFound, "页面不存在") return } @@ -69,7 +71,7 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) { // 查询待审核类型(用于前端显示审核提示) pendingTypes, _ := sc.auditService.GetPendingTypes(uid) - c.HTML(http.StatusOK, "settings/index.html", common.BuildPageData(c, gin.H{ + data := gin.H{ "Title": "个人设置", "ExtraCSS": "/static/css/settings.css", "ActiveTab": tab, @@ -78,7 +80,34 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) { "StatusName": model.StatusDisplayNames[user.Status], "PendingTypes": pendingTypes, "AuditTypes": model.AuditTypeNames, - })) + } + + // 登录管理 tab 需要额外数据 + if tab == "sessions" { + sessions, _ := sc.sessionMgr.ListByUID(uid) + if sessions == nil { + sessions = []*session.Session{} + } + currentSID, _ := c.Cookie(common.SessionCookieName) + // 解析每个 session 的设备信息 + type sessionView struct { + *session.Session + DeviceInfo session.DeviceInfo + IsCurrent bool + } + var views []sessionView + for _, s := range sessions { + views = append(views, sessionView{ + Session: s, + DeviceInfo: session.ParseUA(s.UserAgent), + IsCurrent: s.ID == currentSID, + }) + } + data["Sessions"] = views + data["CurrentSID"] = currentSID + } + + c.HTML(http.StatusOK, "settings/index.html", common.BuildPageData(c, data)) } // AuditStatus 查询当前用户的待审核类型(需登录) diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index ac77297..e7b3247 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -78,4 +78,5 @@ func injectSessionContext(c *gin.Context, s *session.Session) { c.Set("email", s.Email) c.Set("username", s.Username) c.Set("role", s.Role) + c.Set("sid", s.ID) } diff --git a/internal/router/api.go b/internal/router/api.go index b94ca17..1b803b4 100644 --- a/internal/router/api.go +++ b/internal/router/api.go @@ -19,6 +19,11 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { settingsAPI.PUT("/password", d.settingsCtrl.ChangePassword) settingsAPI.POST("/delete-account", d.settingsCtrl.DeleteAccount) settingsAPI.GET("/audit-status", d.settingsCtrl.AuditStatus) + // 登录管理 + settingsAPI.GET("/sessions", d.settingsCtrl.ListSessions) + settingsAPI.DELETE("/sessions/:sid", d.settingsCtrl.DestroySession) + settingsAPI.POST("/sessions/destroy-others", d.settingsCtrl.DestroyOtherSessions) + settingsAPI.PUT("/sessions/:sid/remark", d.settingsCtrl.UpdateSessionRemark) } // --- 消息中心 API(需登录) --- diff --git a/internal/router/deps_extra.go b/internal/router/deps_extra.go index acb4090..ade0496 100644 --- a/internal/router/deps_extra.go +++ b/internal/router/deps_extra.go @@ -22,7 +22,7 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting messageController := controller.NewMessageController(notificationService) - settingsCtrl := controller.NewSettingsController(authService, avatarSvc, auditService) + settingsCtrl := controller.NewSettingsController(authService, avatarSvc, auditService, sessionMgr) siteSettingController := adminCtrl.NewSiteSettingController( service.NewSiteSettingService(siteSettings), &service.AuditDefaults{ diff --git a/internal/session/manager.go b/internal/session/manager.go index 9fdf877..4959a07 100644 --- a/internal/session/manager.go +++ b/internal/session/manager.go @@ -26,7 +26,7 @@ func NewManager(store Store, userRepo userStore, cfg *config.Config) *Manager { } // Create 创建会话并写入存储 -func (m *Manager) Create(user *model.User, rememberMe bool) (string, error) { +func (m *Manager) Create(user *model.User, rememberMe bool, ip, userAgent string) (string, error) { sid, err := NewID() if err != nil { return "", err @@ -39,13 +39,15 @@ func (m *Manager) Create(user *model.User, rememberMe bool) (string, error) { Username: user.Username, Role: user.Role, RememberMe: rememberMe, + IP: ip, + UserAgent: userAgent, CreatedAt: now, LastAccess: now, } if err := m.store.Set(s); err != nil { return "", err } - log.Printf("[SessionManager] Created sid=%s uid=%d rememberMe=%v", sid[:16]+"...", user.ID, rememberMe) + log.Printf("[SessionManager] Created sid=%s uid=%d rememberMe=%v ip=%s", sid[:16]+"...", user.ID, rememberMe, ip) return sid, nil } @@ -102,3 +104,26 @@ func (m *Manager) IdleTimeout(rememberMe bool) time.Duration { } return time.Duration(m.cfg.Session.IdleTimeout) * time.Minute } + +// ListByUID 列出某用户的所有活跃会话 +func (m *Manager) ListByUID(uid uint) ([]*Session, error) { + return m.store.ListByUID(uid) +} + +// DestroyOtherByUID 销毁某用户除当前会话外的所有会话 +func (m *Manager) DestroyOtherByUID(uid uint, currentSID string) error { + return m.store.DeleteByUIDExclude(uid, currentSID) +} + +// UpdateRemark 更新指定会话的备注(需验证归属) +func (m *Manager) UpdateRemark(sid string, uid uint, remark string) error { + s, err := m.store.Get(sid) + if err != nil || s == nil { + return err + } + if s.UserID != uid { + return nil + } + s.Remark = remark + return m.store.Set(s) +} diff --git a/internal/session/memory_store.go b/internal/session/memory_store.go index 1a2eb48..7fba7ad 100644 --- a/internal/session/memory_store.go +++ b/internal/session/memory_store.go @@ -91,6 +91,59 @@ func (ms *MemoryStore) DeleteByUID(uid uint) error { return nil } +// DeleteByUIDExclude 删除某用户的所有会话,但保留 excludeSID +func (ms *MemoryStore) DeleteByUIDExclude(uid uint, excludeSID string) error { + ms.mu.Lock() + defer ms.mu.Unlock() + + sids, ok := ms.byUID[uid] + if !ok { + return nil + } + var kept []string + var removed int + for _, sid := range sids { + if sid == excludeSID { + kept = append(kept, sid) + continue + } + delete(ms.sessions, sid) + removed++ + } + if len(kept) > 0 { + ms.byUID[uid] = kept + } else { + delete(ms.byUID, uid) + } + log.Printf("[MemoryStore] DeleteByUIDExclude uid=%d removed=%d kept=%d", uid, removed, len(kept)) + return nil +} + +// ListByUID 列出某用户的所有活跃会话(已过期的会被清理掉) +func (ms *MemoryStore) ListByUID(uid uint) ([]*Session, error) { + ms.mu.RLock() + sids, ok := ms.byUID[uid] + if !ok { + ms.mu.RUnlock() + return nil, nil + } + // 复制 sid 列表避免持锁期间修改 + sidCopy := make([]string, len(sids)) + copy(sidCopy, sids) + ms.mu.RUnlock() + + var result []*Session + for _, sid := range sidCopy { + // Get 会检查过期并自动清理 + s, err := ms.Get(sid) + if err != nil || s == nil { + continue + } + result = append(result, s) + } + return result, nil +} + // Cleanup 清理过期会话(根据 RememberMe 选择对应超时) func (ms *MemoryStore) Cleanup() int { ms.mu.Lock() diff --git a/internal/session/session.go b/internal/session/session.go index f7ce729..0088742 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -14,6 +14,9 @@ type Session struct { Username string // 用户名 Role string // 角色 RememberMe bool // 是否持久化(决定 cookie maxAge) + IP string // 登录 IP + UserAgent string // 登录设备 User-Agent + Remark string // 用户备注(如"我的笔记本") CreatedAt time.Time // 创建时间 LastAccess time.Time // 最后访问时间(滑动窗口) } diff --git a/internal/session/store.go b/internal/session/store.go index 8b267c7..24baa3c 100644 --- a/internal/session/store.go +++ b/internal/session/store.go @@ -14,6 +14,12 @@ type Store interface { // DeleteByUID 删除某用户的所有会话(改密/注销/强制下线时调用) DeleteByUID(uid uint) error + // DeleteByUIDExclude 删除某用户的所有会话,但保留指定的会话 ID + DeleteByUIDExclude(uid uint, excludeSID string) error + + // ListByUID 列出某用户的所有活跃会话 + ListByUID(uid uint) ([]*Session, error) + // Cleanup 清理过期会话,返回清理数量(由后台 goroutine 定期调用) Cleanup() int } diff --git a/internal/session/ua.go b/internal/session/ua.go new file mode 100644 index 0000000..1fa6db0 --- /dev/null +++ b/internal/session/ua.go @@ -0,0 +1,97 @@ +package session + +import "strings" + +// DeviceInfo 从 User-Agent 解析出的设备摘要信息 +type DeviceInfo struct { + Device string // 设备类型:Desktop / Mobile / Tablet + OS string // 操作系统:Windows 10 / macOS / Android 12 等 + Browser string // 浏览器:Chrome 120 / Firefox 121 / Safari 17 等 +} + +// ParseUA 从原始 User-Agent 字符串解析设备信息(轻量级,不引入第三方库) +func ParseUA(ua string) DeviceInfo { + info := DeviceInfo{Device: "Desktop"} + + lower := strings.ToLower(ua) + + // --- 操作系统 --- + switch { + case strings.Contains(lower, "windows nt 10"): + info.OS = "Windows 10/11" + case strings.Contains(lower, "windows nt 6.3"): + info.OS = "Windows 8.1" + case strings.Contains(lower, "windows nt 6.2"): + info.OS = "Windows 8" + case strings.Contains(lower, "windows nt 6.1"): + info.OS = "Windows 7" + case strings.Contains(lower, "windows"): + info.OS = "Windows" + case strings.Contains(lower, "mac os x"): + info.OS = "macOS" + if strings.Contains(lower, "iphone") { + info.OS = "iOS" + info.Device = "Mobile" + } else if strings.Contains(lower, "ipad") { + info.OS = "iPadOS" + info.Device = "Tablet" + } + case strings.Contains(lower, "android"): + info.OS = "Android" + info.Device = "Mobile" + if strings.Contains(lower, "tablet") || strings.Contains(lower, "ipad") { + info.Device = "Tablet" + } + case strings.Contains(lower, "iphone"): + info.OS = "iOS" + info.Device = "Mobile" + case strings.Contains(lower, "ipad"): + info.OS = "iPadOS" + info.Device = "Tablet" + case strings.Contains(lower, "linux"): + info.OS = "Linux" + case strings.Contains(lower, "cros"): + info.OS = "ChromeOS" + default: + info.OS = "Unknown" + } + + // --- 浏览器 --- + switch { + case strings.Contains(lower, "edg/"): + info.Browser = "Edge" + info.Browser += extractVersion(ua, "Edg/") + case strings.Contains(lower, "chrome/") && !strings.Contains(lower, "edg/"): + info.Browser = "Chrome" + info.Browser += extractVersion(ua, "Chrome/") + case strings.Contains(lower, "firefox/"): + info.Browser = "Firefox" + info.Browser += extractVersion(ua, "Firefox/") + case strings.Contains(lower, "safari/") && !strings.Contains(lower, "chrome/"): + info.Browser = "Safari" + info.Browser += extractVersion(ua, "Version/") + default: + info.Browser = "Unknown" + } + + return info +} + +// extractVersion 从 UA 中提取主版本号 +func extractVersion(ua, prefix string) string { + idx := strings.Index(ua, prefix) + if idx == -1 { + return "" + } + rest := ua[idx+len(prefix):] + dot := strings.Index(rest, ".") + if dot == -1 { + // 无点号,取到空格或结尾 + sp := strings.Index(rest, " ") + if sp > 0 { + return " " + rest[:sp] + } + return "" + } + return " " + rest[:dot] +} diff --git a/templates/MetaLab-2026/html/settings/index.html b/templates/MetaLab-2026/html/settings/index.html index 8c905da..ed9abb5 100644 --- a/templates/MetaLab-2026/html/settings/index.html +++ b/templates/MetaLab-2026/html/settings/index.html @@ -24,6 +24,15 @@ 账号信息 + + + 登录管理 + @@ -59,7 +68,56 @@
查看和管理你的登录设备,保护账号安全
+查看和管理你的登录设备,保护账号安全
+查看和管理你的登录设备,保护账号安全
+