diff --git a/CHANGELOG-server.md b/CHANGELOG-server.md index b8ee6d6..b74754f 100644 --- a/CHANGELOG-server.md +++ b/CHANGELOG-server.md @@ -5,6 +5,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.56] - 2026-06-17 + +### 新功能 +- 在线设备/会话管理:服务端记录每次登录会话,支持查看本店在线设备、按平台类(桌面端 / 移动端 / 网页端)分别限制并发登录数,管理员可强制将指定设备下线 +- 登录失败锁定:同一账号连续多次密码错误后自动锁定一段时间,防止暴力破解 +- 记录账号最近登录时间 + +### 修复 +- 修复被禁用账号仍能凭刷新令牌继续续期登录的安全漏洞:续期时校验账号状态与会话是否有效 + ## [1.0.55] - 2026-06-17 ### 新功能 diff --git a/backend/config/config.go b/backend/config/config.go index ff994a4..5b7608f 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -13,6 +13,7 @@ type Config struct { JWT JWTConfig License LicenseConfig Storage StorageConfig + Session SessionConfig } type ServerConfig struct { @@ -39,6 +40,15 @@ type LicenseConfig struct { Ed25519PrivateKey string `mapstructure:"ed25519_private_key"` // base64 Ed25519 private key for token signing (keep in Bitwarden) } +// SessionConfig 登录会话与并发限制(全局默认,可被每店 session_policy 覆盖)。 +type SessionConfig struct { + LimitDesktop int `mapstructure:"limit_desktop"` // 桌面端(win/mac/linux)最大并发会话,0=禁止 + LimitMobile int `mapstructure:"limit_mobile"` // 移动端(android/ios)最大并发会话,0=禁止 + LimitWeb int `mapstructure:"limit_web"` // web 端最大并发会话,0=禁止 + MaxFailures int `mapstructure:"max_failures"` // 连续登录失败几次后锁定 + LockMinutes int `mapstructure:"lock_minutes"` // 锁定时长(分钟) +} + type StorageConfig struct { UploadDir string `mapstructure:"upload_dir"` BaseURL string `mapstructure:"base_url"` @@ -75,6 +85,11 @@ func Load() { viper.SetDefault("server.cors_origin", "*") viper.SetDefault("jwt.access_expire_min", 60) viper.SetDefault("jwt.refresh_expire_h", 168) // 7天 + viper.SetDefault("session.limit_desktop", 2) + viper.SetDefault("session.limit_mobile", 2) + viper.SetDefault("session.limit_web", 2) // 默认不禁 web(官网挂着 Web 版 app);设 0 可禁 + viper.SetDefault("session.max_failures", 5) + viper.SetDefault("session.lock_minutes", 15) viper.SetDefault("database.max_idle_conns", 10) viper.SetDefault("database.max_open_conns", 100) viper.SetDefault("storage.upload_dir", "./uploads/images") diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index e7d89b9..a20ae09 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -1,9 +1,11 @@ package handler import ( + "errors" "net/http" "github.com/gin-gonic/gin" + "github.com/wangjia/jiu/backend/internal/middleware" "github.com/wangjia/jiu/backend/internal/service" "github.com/wangjia/jiu/backend/internal/util" ) @@ -19,18 +21,34 @@ func NewAuthHandler(svc *service.AuthService) *AuthHandler { // Login POST /api/v1/auth/login func (h *AuthHandler) Login(c *gin.Context) { var req struct { - ShopCode string `json:"shop_code" binding:"required"` - Username string `json:"username" binding:"required"` - Password string `json:"password" binding:"required"` + ShopCode string `json:"shop_code" binding:"required"` + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + DeviceID string `json:"device_id"` + DeviceName string `json:"device_name"` + Platform string `json:"platform"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - pair, user, err := h.svc.Login(req.ShopCode, req.Username, req.Password) + dev := service.DeviceInfo{ + DeviceID: req.DeviceID, + DeviceName: req.DeviceName, + Platform: req.Platform, + IP: c.ClientIP(), + UserAgent: c.Request.UserAgent(), + } + pair, user, err := h.svc.Login(req.ShopCode, req.Username, req.Password, dev) if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + status := http.StatusUnauthorized + if errors.Is(err, service.ErrTooManyAttempts) { + status = http.StatusTooManyRequests + } else if errors.Is(err, service.ErrPlatformNotAllowed) { + status = http.StatusForbidden + } + c.JSON(status, gin.H{"error": err.Error()}) return } @@ -79,9 +97,28 @@ func (h *AuthHandler) Refresh(c *gin.Context) { pair, err := h.svc.RefreshTokens(req.RefreshToken) if err != nil { + // 被踢/登出/禁用 → 给前端明确 code,便于提示"账号已在其他设备登录/已失效" + if errors.Is(err, service.ErrSessionRevoked) || errors.Is(err, service.ErrUserInactive) { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error(), "code": "SESSION_REVOKED"}) + return + } c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) return } util.RespondSuccess(c, pair) } + +// Logout POST /api/v1/auth/logout —— 撤销当前会话 +func (h *AuthHandler) Logout(c *gin.Context) { + if err := h.svc.Logout(middleware.GetSID(c)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + util.RespondSuccess(c, gin.H{"ok": true}) +} + +// Ping POST /api/v1/auth/ping —— 心跳,仅触发中间件刷新 last_seen;会话已撤销则中间件直接 401 +func (h *AuthHandler) Ping(c *gin.Context) { + util.RespondSuccess(c, gin.H{"ok": true}) +} diff --git a/backend/internal/handler/feedback_test.go b/backend/internal/handler/feedback_test.go index 72bba70..dacb4f1 100644 --- a/backend/internal/handler/feedback_test.go +++ b/backend/internal/handler/feedback_test.go @@ -24,7 +24,7 @@ func TestFeedbackHandler_SubmitAndList(t *testing.T) { r := gin.New() r.Use(gin.Recovery()) api := r.Group("/api/v1") - api.Use(middleware.JWT()) + api.Use(middleware.JWT(db)) api.POST("/feedback", fh.Submit) adminG := api.Group("/admin") adminG.Use(middleware.SuperAdminOnly()) diff --git a/backend/internal/handler/session.go b/backend/internal/handler/session.go new file mode 100644 index 0000000..89cc99b --- /dev/null +++ b/backend/internal/handler/session.go @@ -0,0 +1,52 @@ +package handler + +import ( + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "github.com/wangjia/jiu/backend/internal/middleware" + "github.com/wangjia/jiu/backend/internal/service" + "github.com/wangjia/jiu/backend/internal/util" +) + +type SessionHandler struct { + svc *service.AuthService +} + +func NewSessionHandler(svc *service.AuthService) *SessionHandler { + return &SessionHandler{svc: svc} +} + +// List GET /api/v1/sessions —— 列出本店在线会话(所有登录用户只读) +func (h *SessionHandler) List(c *gin.Context) { + shopID := middleware.GetShopID(c) + views, err := h.svc.ListSessions(shopID, middleware.GetSID(c)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + util.RespondSuccess(c, views) +} + +// ForceLogout DELETE /api/v1/sessions/:id —— 强制下线(仅 admin/superadmin,挂 AdminOnly) +func (h *SessionHandler) ForceLogout(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session id"}) + return + } + shopID := middleware.GetShopID(c) + if err := h.svc.ForceLogout(shopID, id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + util.RespondSuccess(c, gin.H{"ok": true}) +} diff --git a/backend/internal/handler/testhelper_test.go b/backend/internal/handler/testhelper_test.go index 43cb9e4..9b5c039 100644 --- a/backend/internal/handler/testhelper_test.go +++ b/backend/internal/handler/testhelper_test.go @@ -32,7 +32,7 @@ func setupProtectedRouter(db *gorm.DB) *gin.Engine { r.Use(gin.Recovery()) api := r.Group("/api/v1") - api.Use(middleware.JWT()) + api.Use(middleware.JWT(db)) // 商品路由 products := api.Group("/products") diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index 26033a6..a12a7fe 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -3,17 +3,22 @@ package middleware import ( "net/http" "strings" + "time" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" + "gorm.io/gorm" + "github.com/wangjia/jiu/backend/config" + "github.com/wangjia/jiu/backend/internal/model" ) type Claims struct { - UserID uint64 `json:"user_id"` - ShopID uint64 `json:"shop_id"` - Role string `json:"role"` - LicenseExpiresAt *int64 `json:"lic_exp,omitempty"` // unix seconds; nil = perpetual + UserID uint64 `json:"user_id"` + ShopID uint64 `json:"shop_id"` + Role string `json:"role"` + SID string `json:"sid,omitempty"` // 服务端会话标识(user_sessions.sid) + LicenseExpiresAt *int64 `json:"lic_exp,omitempty"` // unix seconds; nil = perpetual jwt.RegisteredClaims } @@ -21,10 +26,14 @@ const ( CtxUserID = "user_id" CtxShopID = "shop_id" CtxRole = "role" + CtxSID = "sid" CtxLicenseExpiresAt = "lic_exp" ) -func JWT() gin.HandlerFunc { +// lastSeenThrottle 控制 last_seen_at 写频率,避免每请求一写。 +const lastSeenThrottle = 30 * time.Second + +func JWT(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { authHeader := c.GetHeader("Authorization") if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { @@ -42,14 +51,44 @@ func JWT() gin.HandlerFunc { return } + // 会话校验:带 sid 的 token 必须对应一条未撤销会话(支持踢人/登出/禁用即时失效)。 + // 存量无 sid 的 token 过渡放行(其 access ≤60min 过期后会换到带 sid 的会话)。 + if claims.SID != "" { + var sess model.UserSession + if err := db.Where("sid = ?", claims.SID).First(&sess).Error; err != nil || sess.RevokedAt != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session revoked", "code": "SESSION_REVOKED"}) + return + } + // 节流刷新 last_seen,用于在线状态判定 + if time.Since(sess.LastSeenAt) > lastSeenThrottle { + db.Model(&model.UserSession{}).Where("id = ?", sess.ID). + Update("last_seen_at", time.Now()) + } + } + c.Set(CtxUserID, claims.UserID) c.Set(CtxShopID, claims.ShopID) c.Set(CtxRole, claims.Role) + c.Set(CtxSID, claims.SID) c.Set(CtxLicenseExpiresAt, claims.LicenseExpiresAt) c.Next() } } +// GetSID 从 context 中获取当前会话 sid(可能为空:存量无 sid token)。 +func GetSID(c *gin.Context) string { + v, _ := c.Get(CtxSID) + s, _ := v.(string) + return s +} + +// GetRole 从 context 中获取当前用户角色。 +func GetRole(c *gin.Context) string { + v, _ := c.Get(CtxRole) + s, _ := v.(string) + return s +} + // AdminOnly 仅管理员可访问 func AdminOnly() gin.HandlerFunc { return func(c *gin.Context) { diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go index 5afa111..55c7911 100644 --- a/backend/internal/model/user.go +++ b/backend/internal/model/user.go @@ -1,13 +1,16 @@ package model +import "time" + type User struct { Base - ShopID uint64 `gorm:"not null;index;uniqueIndex:uk_shop_username" json:"shop_id"` - Username string `gorm:"size:50;uniqueIndex:uk_shop_username" json:"username"` - PasswordHash string `gorm:"size:255" json:"-"` - RealName string `gorm:"size:50" json:"real_name"` - Phone string `gorm:"size:30" json:"phone"` - Role string `gorm:"type:enum('admin','operator','readonly','superadmin');default:'operator'" json:"role"` - IsActive bool `gorm:"default:true" json:"is_active"` - CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"` + ShopID uint64 `gorm:"not null;index;uniqueIndex:uk_shop_username" json:"shop_id"` + Username string `gorm:"size:50;uniqueIndex:uk_shop_username" json:"username"` + PasswordHash string `gorm:"size:255" json:"-"` + RealName string `gorm:"size:50" json:"real_name"` + Phone string `gorm:"size:30" json:"phone"` + Role string `gorm:"type:enum('admin','operator','readonly','superadmin');default:'operator'" json:"role"` + IsActive bool `gorm:"default:true" json:"is_active"` + LastLoginAt *time.Time `json:"last_login_at,omitempty"` + CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"` } diff --git a/backend/internal/model/user_session.go b/backend/internal/model/user_session.go new file mode 100644 index 0000000..308196d --- /dev/null +++ b/backend/internal/model/user_session.go @@ -0,0 +1,39 @@ +package model + +import "time" + +// UserSession 服务端登录会话。JWT 的 sid claim 指向此表一行, +// 用于支持登出/踢人/在线状态监控(JWT 本身无状态,无法撤销)。 +type UserSession struct { + ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` + ShopID uint64 `gorm:"not null;index:idx_session_shop_user" json:"shop_id"` + UserID uint64 `gorm:"not null;index:idx_session_shop_user" json:"user_id"` + SID string `gorm:"column:sid;size:64;not null;uniqueIndex" json:"sid"` // 嵌入 JWT + DeviceID string `gorm:"size:255" json:"device_id"` + DeviceName string `gorm:"size:255" json:"device_name"` + Platform string `gorm:"size:50" json:"platform"` // windows|macos|linux|android|ios|web + PlatformClass string `gorm:"size:20;index" json:"platform_class"` // desktop|mobile|web + IP string `gorm:"size:64" json:"ip"` + UserAgent string `gorm:"size:512" json:"user_agent"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + LastSeenAt time.Time `gorm:"autoCreateTime" json:"last_seen_at"` + RevokedAt *time.Time `gorm:"index" json:"revoked_at,omitempty"` + RevokedReason string `gorm:"size:30" json:"revoked_reason,omitempty"` // kicked|logout|admin|disabled + RefreshExpAt time.Time `json:"refresh_exp_at"` +} + +func (UserSession) TableName() string { return "user_sessions" } + +// PlatformClass 把具体平台归类,用于按类限并发。 +func PlatformClass(platform string) string { + switch platform { + case "android", "ios": + return "mobile" + case "web": + return "web" + case "windows", "macos", "linux": + return "desktop" + default: + return "desktop" // 未知平台按桌面端处理 + } +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 36f118f..78c5450 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -17,6 +17,7 @@ func Setup(r *gin.Engine, db *gorm.DB) { // 处理器 authH := handler.NewAuthHandler(authSvc) + sessionH := handler.NewSessionHandler(authSvc) licenseH := handler.NewLicenseHandler(licenseSvc) productH := handler.NewProductHandler(db) warehouseH := handler.NewWarehouseHandler(db) @@ -69,7 +70,17 @@ func Setup(r *gin.Engine, db *gorm.DB) { // 需要 JWT 的基础路由组 api := v1.Group("") - api.Use(middleware.JWT()) + api.Use(middleware.JWT(db)) + + // 会话/在线状态(豁免 LicenseGuard:登出/心跳/在线列表在锁定期也要可用) + { + api.POST("/auth/logout", authH.Logout) + api.POST("/auth/ping", authH.Ping) + // 在线会话列表:所有登录用户只读 + api.GET("/sessions", sessionH.List) + // 强制下线:仅 admin/superadmin + api.DELETE("/sessions/:id", middleware.AdminOnly(), sessionH.ForceLogout) + } // 许可证路由:豁免 LicenseGuard(锁定时仍需查看状态和激活) license := api.Group("/license") diff --git a/backend/internal/service/auth.go b/backend/internal/service/auth.go index 3f720c8..fdce586 100644 --- a/backend/internal/service/auth.go +++ b/backend/internal/service/auth.go @@ -3,6 +3,7 @@ package service import ( "errors" "fmt" + "sync" "time" "github.com/golang-jwt/jwt/v5" @@ -19,8 +20,65 @@ var ( ErrInvalidCredentials = errors.New("invalid username or password") ErrUserInactive = errors.New("user is disabled") ErrLicenseLocked = errors.New("license locked, please renew or contact support") + ErrPlatformNotAllowed = errors.New("该平台不允许登录") + ErrTooManyAttempts = errors.New("登录失败次数过多,账号已临时锁定,请稍后再试") + ErrSessionRevoked = errors.New("session revoked") ) +// DeviceInfo 登录请求携带的设备信息,用于会话记录与按平台限并发。 +type DeviceInfo struct { + DeviceID string + DeviceName string + Platform string + IP string + UserAgent string +} + +// loginLimiter 内存登录失败限流器(单实例,重启即清零)。 +type loginLimiter struct { + mu sync.Mutex + entries map[string]*limiterEntry +} + +type limiterEntry struct { + failures int + lockedTill time.Time +} + +var loginLim = &loginLimiter{entries: map[string]*limiterEntry{}} + +// locked 返回该 key 是否处于锁定中。 +func (l *loginLimiter) locked(key string) bool { + l.mu.Lock() + defer l.mu.Unlock() + e := l.entries[key] + return e != nil && time.Now().Before(e.lockedTill) +} + +// recordFailure 记一次失败,达到阈值则锁定。 +func (l *loginLimiter) recordFailure(key string) { + l.mu.Lock() + defer l.mu.Unlock() + e := l.entries[key] + if e == nil { + e = &limiterEntry{} + l.entries[key] = e + } + e.failures++ + max := config.C.Session.MaxFailures + if max > 0 && e.failures >= max { + e.lockedTill = time.Now().Add(time.Duration(config.C.Session.LockMinutes) * time.Minute) + e.failures = 0 + } +} + +// reset 登录成功后清除失败计数。 +func (l *loginLimiter) reset(key string) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.entries, key) +} + type AuthService struct { db *gorm.DB } @@ -37,15 +95,22 @@ type TokenPair struct { } // Login 账号密码登录 -func (s *AuthService) Login(shopCode, username, password string) (*TokenPair, *model.User, error) { +func (s *AuthService) Login(shopCode, username, password string, dev DeviceInfo) (*TokenPair, *model.User, error) { + limiterKey := shopCode + "|" + username + if loginLim.locked(limiterKey) { + return nil, nil, ErrTooManyAttempts + } + var shop model.Shop if err := s.db.Where("code = ?", shopCode).First(&shop).Error; err != nil { + loginLim.recordFailure(limiterKey) return nil, nil, ErrInvalidCredentials } var user model.User if err := s.db.Where("shop_id = ? AND username = ? AND deleted_at IS NULL", shop.ID, username). First(&user).Error; err != nil { + loginLim.recordFailure(limiterKey) return nil, nil, ErrInvalidCredentials } @@ -54,6 +119,7 @@ func (s *AuthService) Login(shopCode, username, password string) (*TokenPair, *m } if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { + loginLim.recordFailure(limiterKey) return nil, nil, ErrInvalidCredentials } @@ -61,13 +127,176 @@ func (s *AuthService) Login(shopCode, username, password string) (*TokenPair, *m return nil, nil, err } - pair, err := s.issueTokens(user.ID, shop.ID, user.Role) + // 按平台类限并发:取有效配额,0=禁止该平台,超额则踢最旧会话腾位。 + pclass := model.PlatformClass(dev.Platform) + quota := s.effectiveQuota(&shop, pclass) + if quota <= 0 { + return nil, nil, ErrPlatformNotAllowed + } + + sid := uuid.New().String() + now := time.Now() + if err := s.db.Transaction(func(tx *gorm.DB) error { + // 统计该 user 在该 class 的活跃会话;超额踢最旧 + var active []model.UserSession + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("shop_id = ? AND user_id = ? AND platform_class = ? AND revoked_at IS NULL", + shop.ID, user.ID, pclass). + Order("last_seen_at ASC").Find(&active).Error; err != nil { + return err + } + // 需要腾出 (active+1) - quota 个位置 + for i := 0; i <= len(active)-quota; i++ { + if err := tx.Model(&model.UserSession{}).Where("id = ?", active[i].ID). + Updates(map[string]interface{}{"revoked_at": now, "revoked_reason": "kicked"}).Error; err != nil { + return err + } + } + + sess := model.UserSession{ + ShopID: shop.ID, + UserID: user.ID, + SID: sid, + DeviceID: dev.DeviceID, + DeviceName: dev.DeviceName, + Platform: dev.Platform, + PlatformClass: pclass, + IP: dev.IP, + UserAgent: dev.UserAgent, + LastSeenAt: now, + RefreshExpAt: now.Add(time.Duration(config.C.JWT.RefreshExpireH) * time.Hour), + } + if err := tx.Create(&sess).Error; err != nil { + return err + } + return tx.Model(&model.User{}).Where("id = ?", user.ID).Update("last_login_at", now).Error + }); err != nil { + return nil, nil, err + } + + loginLim.reset(limiterKey) + user.LastLoginAt = &now + + pair, err := s.issueTokens(user.ID, shop.ID, user.Role, sid) if err != nil { return nil, nil, err } return pair, &user, nil } +// effectiveQuota 返回某店某平台类的有效并发配额:优先每店 session_policy 覆盖,否则全局默认。 +func (s *AuthService) effectiveQuota(shop *model.Shop, pclass string) int { + def := map[string]int{ + "desktop": config.C.Session.LimitDesktop, + "mobile": config.C.Session.LimitMobile, + "web": config.C.Session.LimitWeb, + }[pclass] + + if shop.CustomFields == nil { + return def + } + raw, ok := shop.CustomFields["session_policy"] + if !ok { + return def + } + policy, ok := raw.(map[string]interface{}) + if !ok { + return def + } + v, ok := policy[pclass] + if !ok { + return def + } + switch n := v.(type) { + case float64: + return int(n) + case int: + return n + default: + return def + } +} + +// Logout 撤销指定会话(用户主动登出)。sid 为空(存量 token)时为 no-op。 +func (s *AuthService) Logout(sid string) error { + if sid == "" { + return nil + } + return s.db.Model(&model.UserSession{}). + Where("sid = ? AND revoked_at IS NULL", sid). + Updates(map[string]interface{}{"revoked_at": time.Now(), "revoked_reason": "logout"}).Error +} + +// SessionView 在线会话视图(含用户名,供管理端列表展示)。 +type SessionView struct { + ID uint64 `json:"id"` + UserID uint64 `json:"user_id"` + Username string `json:"username"` + RealName string `json:"real_name"` + Platform string `json:"platform"` + PlatformClass string `json:"platform_class"` + DeviceName string `json:"device_name"` + IP string `json:"ip"` + CreatedAt time.Time `json:"created_at"` + LastSeenAt time.Time `json:"last_seen_at"` + Online bool `json:"online"` + IsCurrent bool `json:"is_current"` +} + +// OnlineThreshold last_seen 在此时间内视为在线。 +const OnlineThreshold = 90 * time.Second + +// ListSessions 列出本店活跃(未撤销)会话,按最近活跃排序。currentSID 标记当前请求自身会话。 +func (s *AuthService) ListSessions(shopID uint64, currentSID string) ([]SessionView, error) { + var rows []struct { + model.UserSession + Username string + RealName string + } + err := s.db.Table("user_sessions AS s"). + Select("s.*, u.username AS username, u.real_name AS real_name"). + Joins("LEFT JOIN users u ON u.id = s.user_id"). + Where("s.shop_id = ? AND s.revoked_at IS NULL", shopID). + Order("s.last_seen_at DESC"). + Scan(&rows).Error + if err != nil { + return nil, err + } + now := time.Now() + views := make([]SessionView, 0, len(rows)) + for _, r := range rows { + views = append(views, SessionView{ + ID: r.ID, + UserID: r.UserID, + Username: r.Username, + RealName: r.RealName, + Platform: r.Platform, + PlatformClass: r.PlatformClass, + DeviceName: r.DeviceName, + IP: r.IP, + CreatedAt: r.CreatedAt, + LastSeenAt: r.LastSeenAt, + Online: now.Sub(r.LastSeenAt) <= OnlineThreshold, + IsCurrent: currentSID != "" && r.SID == currentSID, + }) + } + return views, nil +} + +// ForceLogout 管理员强制下线本店某会话(按 id + shop_id 隔离)。 +func (s *AuthService) ForceLogout(shopID, sessionID uint64) error { + res := s.db.Model(&model.UserSession{}). + Where("id = ? AND shop_id = ? AND revoked_at IS NULL", sessionID, shopID). + Updates(map[string]interface{}{"revoked_at": time.Now(), "revoked_reason": "admin"}) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + // RegisterInput 注册新门店所需参数 type RegisterInput struct { ShopName string `json:"shop_name" binding:"required"` @@ -146,7 +375,8 @@ func HashPassword(plain string) (string, error) { return string(b), err } -// RefreshTokens 用 Refresh Token 换新 Token Pair +// RefreshTokens 用 Refresh Token 换新 Token Pair。 +// 修复历史漏洞:续期时重新查库校验用户/会话状态,被禁用或被踢的用户无法再续命。 func (s *AuthService) RefreshTokens(refreshToken string) (*TokenPair, error) { claims := &middleware.Claims{} token, err := jwt.ParseWithClaims(refreshToken, claims, func(t *jwt.Token) (interface{}, error) { @@ -155,7 +385,35 @@ func (s *AuthService) RefreshTokens(refreshToken string) (*TokenPair, error) { if err != nil || !token.Valid { return nil, errors.New("invalid refresh token") } - return s.issueTokens(claims.UserID, claims.ShopID, claims.Role) + + // 带 sid 的 token 必须对应未撤销会话(被踢/登出后无法续期)。 + if claims.SID != "" { + var sess model.UserSession + if err := s.db.Where("sid = ?", claims.SID).First(&sess).Error; err != nil || sess.RevokedAt != nil { + return nil, ErrSessionRevoked + } + } + + // 重新加载用户:不存在或已禁用 → 拒绝续期(修复漏洞)。 + var user model.User + if err := s.db.Where("id = ? AND deleted_at IS NULL", claims.UserID).First(&user).Error; err != nil { + return nil, ErrInvalidCredentials + } + if !user.IsActive { + return nil, ErrUserInactive + } + + if err := s.checkLicenseNotLocked(user.ShopID); err != nil { + return nil, err + } + + // 刷新会话存活时间(同 sid 续期)。 + if claims.SID != "" { + s.db.Model(&model.UserSession{}).Where("sid = ?", claims.SID). + Update("last_seen_at", time.Now()) + } + + return s.issueTokens(user.ID, user.ShopID, user.Role, claims.SID) } func (s *AuthService) checkLicenseNotLocked(shopID uint64) error { @@ -170,7 +428,7 @@ func (s *AuthService) checkLicenseNotLocked(shopID uint64) error { return nil } -func (s *AuthService) issueTokens(userID, shopID uint64, role string) (*TokenPair, error) { +func (s *AuthService) issueTokens(userID, shopID uint64, role, sid string) (*TokenPair, error) { cfg := config.C.JWT now := time.Now() @@ -188,6 +446,7 @@ func (s *AuthService) issueTokens(userID, shopID uint64, role string) (*TokenPai UserID: userID, ShopID: shopID, Role: role, + SID: sid, LicenseExpiresAt: licExpAt, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(accessExp), @@ -204,6 +463,7 @@ func (s *AuthService) issueTokens(userID, shopID uint64, role string) (*TokenPai UserID: userID, ShopID: shopID, Role: role, + SID: sid, LicenseExpiresAt: licExpAt, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(refreshExp), diff --git a/backend/internal/service/auth_test.go b/backend/internal/service/auth_test.go index b2fc93d..ea44cf7 100644 --- a/backend/internal/service/auth_test.go +++ b/backend/internal/service/auth_test.go @@ -15,7 +15,7 @@ func TestAuthService_Login_Success(t *testing.T) { testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin") svc := NewAuthService(db) - pair, user, err := svc.Login("HOTEL001", "admin", "password123") + pair, user, err := svc.Login("HOTEL001", "admin", "password123", DeviceInfo{Platform: "windows"}) require.NoError(t, err) require.NotNil(t, pair) @@ -31,7 +31,7 @@ func TestAuthService_Login_WrongPassword(t *testing.T) { testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin") svc := NewAuthService(db) - pair, user, err := svc.Login("HOTEL002", "admin", "wrongpassword") + pair, user, err := svc.Login("HOTEL002", "admin", "wrongpassword", DeviceInfo{Platform: "windows"}) assert.Error(t, err) assert.Equal(t, ErrInvalidCredentials, err) @@ -44,7 +44,7 @@ func TestAuthService_Login_WrongHotel(t *testing.T) { testutil.CreateTestShop(db, "HOTEL003") svc := NewAuthService(db) - pair, user, err := svc.Login("NONEXISTENT", "admin", "password123") + pair, user, err := svc.Login("NONEXISTENT", "admin", "password123", DeviceInfo{Platform: "windows"}) assert.Error(t, err) assert.Equal(t, ErrInvalidCredentials, err) @@ -60,7 +60,7 @@ func TestAuthService_Login_DisabledUser(t *testing.T) { db.Model(user).Update("is_active", false) svc := NewAuthService(db) - pair, u, err := svc.Login("HOTEL004", "disabled", "password123") + pair, u, err := svc.Login("HOTEL004", "disabled", "password123", DeviceInfo{Platform: "windows"}) assert.Error(t, err) assert.Equal(t, ErrUserInactive, err) @@ -74,7 +74,7 @@ func TestAuthService_Login_WrongUsername(t *testing.T) { testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin") svc := NewAuthService(db) - pair, user, err := svc.Login("HOTEL005", "nonexistent", "password123") + pair, user, err := svc.Login("HOTEL005", "nonexistent", "password123", DeviceInfo{Platform: "windows"}) assert.Error(t, err) assert.Equal(t, ErrInvalidCredentials, err) @@ -88,7 +88,7 @@ func TestAuthService_RefreshTokens(t *testing.T) { testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin") svc := NewAuthService(db) - pair, _, err := svc.Login("HOTEL006", "admin", "password123") + pair, _, err := svc.Login("HOTEL006", "admin", "password123", DeviceInfo{Platform: "windows"}) require.NoError(t, err) require.NotNil(t, pair) diff --git a/backend/internal/service/session_test.go b/backend/internal/service/session_test.go new file mode 100644 index 0000000..4b4deee --- /dev/null +++ b/backend/internal/service/session_test.go @@ -0,0 +1,169 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/wangjia/jiu/backend/config" + "github.com/wangjia/jiu/backend/internal/model" + "github.com/wangjia/jiu/backend/testutil" +) + +// 同平台类超配额时踢掉最旧会话。 +func TestLogin_PerClassQuota_KicksOldest(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "SESS01") + testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin") + svc := NewAuthService(db) + + dev := DeviceInfo{Platform: "windows"} + p1, _, err := svc.Login("SESS01", "admin", "password123", dev) + require.NoError(t, err) + p2, _, err := svc.Login("SESS01", "admin", "password123", dev) + require.NoError(t, err) + // 配额 2,第 3 次桌面端登录应踢掉最旧(p1) + _, _, err = svc.Login("SESS01", "admin", "password123", dev) + require.NoError(t, err) + + // 共 2 个未撤销会话 + var active int64 + db.Model(&model.UserSession{}).Where("shop_id = ? AND revoked_at IS NULL", shop.ID).Count(&active) + assert.Equal(t, int64(2), active) + + // p1 的 refresh 应失败(会话被踢) + _, err = svc.RefreshTokens(p1.RefreshToken) + assert.ErrorIs(t, err, ErrSessionRevoked) + // p2 仍可续期 + _, err = svc.RefreshTokens(p2.RefreshToken) + assert.NoError(t, err) +} + +// 移动端与桌面端配额互不影响。 +func TestLogin_QuotaIsolatedByClass(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "SESS02") + testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin") + svc := NewAuthService(db) + + _, _, err := svc.Login("SESS02", "admin", "password123", DeviceInfo{Platform: "windows"}) + require.NoError(t, err) + _, _, err = svc.Login("SESS02", "admin", "password123", DeviceInfo{Platform: "android"}) + require.NoError(t, err) + + var active int64 + db.Model(&model.UserSession{}).Where("shop_id = ? AND revoked_at IS NULL", shop.ID).Count(&active) + assert.Equal(t, int64(2), active) // 桌面 1 + 移动 1,都没被踢 +} + +// 配额为 0 的平台拒绝登录(如禁 web)。 +func TestLogin_PlatformDisabled(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "SESS03") + testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin") + svc := NewAuthService(db) + + old := config.C.Session.LimitWeb + config.C.Session.LimitWeb = 0 + defer func() { config.C.Session.LimitWeb = old }() + + _, _, err := svc.Login("SESS03", "admin", "password123", DeviceInfo{Platform: "web"}) + assert.ErrorIs(t, err, ErrPlatformNotAllowed) +} + +// 每店 session_policy 覆盖全局默认。 +func TestLogin_PerShopPolicyOverride(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "SESS04") + // 该店限桌面 1 + db.Model(&model.Shop{}).Where("id = ?", shop.ID). + Update("custom_fields", model.JSON{"session_policy": map[string]interface{}{"desktop": float64(1)}}) + testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin") + svc := NewAuthService(db) + + p1, _, err := svc.Login("SESS04", "admin", "password123", DeviceInfo{Platform: "windows"}) + require.NoError(t, err) + _, _, err = svc.Login("SESS04", "admin", "password123", DeviceInfo{Platform: "windows"}) + require.NoError(t, err) + + // 第 2 次登录应把 p1 踢掉(配额 1) + _, err = svc.RefreshTokens(p1.RefreshToken) + assert.ErrorIs(t, err, ErrSessionRevoked) +} + +// 被禁用用户无法用 refresh token 续命(修复历史漏洞)。 +func TestRefresh_DisabledUserRejected(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "SESS05") + user := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin") + svc := NewAuthService(db) + + pair, _, err := svc.Login("SESS05", "admin", "password123", DeviceInfo{Platform: "windows"}) + require.NoError(t, err) + + db.Model(user).Update("is_active", false) + + _, err = svc.RefreshTokens(pair.RefreshToken) + assert.ErrorIs(t, err, ErrUserInactive) +} + +// 管理员强制下线后,该会话 token 无法续期。 +func TestForceLogout_RevokesSession(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "SESS06") + testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin") + svc := NewAuthService(db) + + pair, _, err := svc.Login("SESS06", "admin", "password123", DeviceInfo{Platform: "windows"}) + require.NoError(t, err) + + views, err := svc.ListSessions(shop.ID, "") + require.NoError(t, err) + require.Len(t, views, 1) + assert.True(t, views[0].Online) + + require.NoError(t, svc.ForceLogout(shop.ID, views[0].ID)) + + _, err = svc.RefreshTokens(pair.RefreshToken) + assert.ErrorIs(t, err, ErrSessionRevoked) + + // 列表中不再出现 + views, err = svc.ListSessions(shop.ID, "") + require.NoError(t, err) + assert.Len(t, views, 0) +} + +// 强制下线跨店隔离:不能下线别店会话。 +func TestForceLogout_TenantIsolation(t *testing.T) { + db := testutil.SetupTestDB() + shopA := testutil.CreateTestShop(db, "SESS07A") + shopB := testutil.CreateTestShop(db, "SESS07B") + testutil.CreateTestUser(db, shopA.ID, "admin", "password123", "admin") + svc := NewAuthService(db) + + _, _, err := svc.Login("SESS07A", "admin", "password123", DeviceInfo{Platform: "windows"}) + require.NoError(t, err) + views, _ := svc.ListSessions(shopA.ID, "") + require.Len(t, views, 1) + + // 用 shopB 的 shopID 尝试下线 shopA 的会话 → 找不到 + err = svc.ForceLogout(shopB.ID, views[0].ID) + assert.Error(t, err) +} + +// 连续登录失败达到阈值后锁定。 +func TestLogin_LockoutAfterFailures(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "SESS08") + testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin") + svc := NewAuthService(db) + + for i := 0; i < config.C.Session.MaxFailures; i++ { + _, _, err := svc.Login("SESS08", "admin", "wrong", DeviceInfo{Platform: "windows"}) + assert.ErrorIs(t, err, ErrInvalidCredentials) + } + // 锁定后即便密码正确也被拒 + _, _, err := svc.Login("SESS08", "admin", "password123", DeviceInfo{Platform: "windows"}) + assert.ErrorIs(t, err, ErrTooManyAttempts) +} diff --git a/backend/main.go b/backend/main.go index 8d664f6..7de0ad1 100644 --- a/backend/main.go +++ b/backend/main.go @@ -97,6 +97,7 @@ func autoMigrate(db *gorm.DB) { &model.User{}, &model.License{}, &model.LicenseDevice{}, + &model.UserSession{}, &model.ProductCategory{}, &model.Product{}, &model.Warehouse{}, diff --git a/backend/schema/schema.sql b/backend/schema/schema.sql index 59fb030..0b6067d 100644 --- a/backend/schema/schema.sql +++ b/backend/schema/schema.sql @@ -42,6 +42,7 @@ CREATE TABLE IF NOT EXISTS `users` ( `phone` VARCHAR(30) DEFAULT NULL, `role` ENUM('admin','operator','readonly','superadmin') NOT NULL DEFAULT 'operator', `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `last_login_at` DATETIME DEFAULT NULL COMMENT '最近登录时间', `custom_fields` JSON DEFAULT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -52,6 +53,32 @@ CREATE TABLE IF NOT EXISTS `users` ( KEY `idx_deleted_at` (`deleted_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户'; +-- ------------------------------------------------------------ +-- 登录会话(支持登出/踢人/在线状态监控;JWT 的 sid claim 指向此表) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `user_sessions` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `shop_id` BIGINT UNSIGNED NOT NULL, + `user_id` BIGINT UNSIGNED NOT NULL, + `sid` VARCHAR(64) NOT NULL COMMENT '嵌入 JWT 的会话标识', + `device_id` VARCHAR(255) DEFAULT NULL, + `device_name` VARCHAR(255) DEFAULT NULL, + `platform` VARCHAR(50) DEFAULT NULL COMMENT 'windows|macos|linux|android|ios|web', + `platform_class` VARCHAR(20) DEFAULT NULL COMMENT 'desktop|mobile|web', + `ip` VARCHAR(64) DEFAULT NULL, + `user_agent` VARCHAR(512) DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `last_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `revoked_at` DATETIME DEFAULT NULL, + `revoked_reason` VARCHAR(30) DEFAULT NULL COMMENT 'kicked|logout|admin|disabled', + `refresh_exp_at` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_session_sid` (`sid`), + KEY `idx_session_shop_user` (`shop_id`, `user_id`), + KEY `idx_session_class` (`platform_class`), + KEY `idx_session_revoked` (`revoked_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='登录会话'; + -- ------------------------------------------------------------ -- 许可证 -- ------------------------------------------------------------ diff --git a/backend/testutil/setup.go b/backend/testutil/setup.go index a7386ba..f0370c3 100644 --- a/backend/testutil/setup.go +++ b/backend/testutil/setup.go @@ -30,6 +30,13 @@ func InitConfig() { License: config.LicenseConfig{ HMACSecret: "test-license-hmac-secret", }, + Session: config.SessionConfig{ + LimitDesktop: 2, + LimitMobile: 2, + LimitWeb: 2, + MaxFailures: 5, + LockMinutes: 15, + }, } } @@ -76,9 +83,27 @@ func SetupTestDB() *gorm.DB { phone TEXT, role TEXT DEFAULT 'operator', is_active INTEGER DEFAULT 1, + last_login_at DATETIME, custom_fields TEXT )`, `CREATE UNIQUE INDEX IF NOT EXISTS uk_shop_username ON users(shop_id, username)`, + `CREATE TABLE IF NOT EXISTS user_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + shop_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + sid TEXT NOT NULL UNIQUE, + device_id TEXT, + device_name TEXT, + platform TEXT, + platform_class TEXT, + ip TEXT, + user_agent TEXT, + created_at DATETIME, + last_seen_at DATETIME, + revoked_at DATETIME, + revoked_reason TEXT, + refresh_exp_at DATETIME + )`, `CREATE TABLE IF NOT EXISTS licenses ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME,