diff --git a/CHANGELOG-server.md b/CHANGELOG-server.md index 0e34f36..ab74918 100644 --- a/CHANGELOG-server.md +++ b/CHANGELOG-server.md @@ -5,6 +5,12 @@ 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.64] - 2026-06-20 + +### 改进 +- 登录设备并发限制改为「按账号统计全部平台合计」:同一账号在桌面、移动、Web 各端加起来最多同时在线 5 台设备(原先各平台分别计数) +- 达到设备上限时,新设备登录会被明确拒绝并提示「已达到登录设备上限,请提升额度或下线其他设备后重试」,不再静默把最早登录的设备顶下线——由用户自行决定下线哪台或提升额度 + ## [1.0.63] - 2026-06-20 ### 新功能 diff --git a/backend/config/config.go b/backend/config/config.go index 7628862..f6df138 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -36,9 +36,10 @@ type JWTConfig struct { // 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=禁止 + LimitTotal int `mapstructure:"limit_total"` // 单用户跨全部平台的最大并发会话总数(超额踢最旧) + 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"` // 同一账号连续登录失败几次后锁定 IPMaxFailures int `mapstructure:"ip_max_failures"` // 同一 IP 跨账号累计失败几次后锁定该 IP(防单 IP 撞多账号) LockMinutes int `mapstructure:"lock_minutes"` // 锁定时长(分钟) @@ -92,7 +93,8 @@ 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_total", 5) // 单用户跨全部平台合计并发上限 + viper.SetDefault("session.limit_desktop", 2) // 平台闸门:>0=允许,0=禁止该平台(数值不再作分平台上限) viper.SetDefault("session.limit_mobile", 2) viper.SetDefault("session.limit_web", 2) // 默认不禁 web(官网挂着 Web 版 app);设 0 可禁 viper.SetDefault("session.max_failures", 5) diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index 48331fa..2e38d2e 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -48,6 +48,9 @@ func (h *AuthHandler) Login(c *gin.Context) { status = http.StatusTooManyRequests } else if errors.Is(err, service.ErrPlatformNotAllowed) { status = http.StatusForbidden + } else if errors.Is(err, service.ErrDeviceLimitReached) { + c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "DEVICE_LIMIT"}) + return } c.JSON(status, gin.H{"error": err.Error()}) return diff --git a/backend/internal/service/auth.go b/backend/internal/service/auth.go index 6eec20b..a1d6bc6 100644 --- a/backend/internal/service/auth.go +++ b/backend/internal/service/auth.go @@ -22,6 +22,7 @@ var ( ErrUserInactive = errors.New("user is disabled") ErrLicenseLocked = errors.New("license locked, please renew or contact support") ErrPlatformNotAllowed = errors.New("该平台不允许登录") + ErrDeviceLimitReached = errors.New("已达到登录设备上限,请提升额度或下线其他设备后重试") ErrTooManyAttempts = errors.New("登录失败次数过多,账号已临时锁定,请稍后再试") ErrSessionRevoked = errors.New("session revoked") @@ -186,32 +187,31 @@ func (s *AuthService) Login(shopCode, username, password string, dev DeviceInfo) // 的 lic_exp 带上新试用到期日。 s.ensureTrialOnFirstUse(shop.ID) - // 按平台类限并发:取有效配额,0=禁止该平台,超额则踢最旧会话腾位。 + // 平台闸门:配额 0=禁止该平台登录(如禁 web);数值不再作分平台上限,并发统一按总数限。 pclass := model.PlatformClass(dev.Platform) - quota := s.effectiveQuota(&shop, pclass) - if quota <= 0 { + if s.effectiveQuota(&shop, pclass) <= 0 { s.recordLoginAttempt(shopCode, username, dev, false, "platform_not_allowed") return nil, nil, ErrPlatformNotAllowed } + // 总并发上限:同一用户跨全部平台合计不超过 totalQuota。已满时拒绝本次登录(不再 + // 静默踢最旧),让用户自行下线其他设备或提升额度。 + totalQuota := s.effectiveTotalQuota(&shop) sid := uuid.New().String() jti := uuid.New().String() // 初始 refresh token jti,后续每次续期轮换 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 { + // 统计该 user 全部平台的活跃会话(不分平台类);已达上限则拒绝 + var activeCount int64 + if err := tx.Model(&model.UserSession{}). + Set("gorm:query_option", "FOR UPDATE"). + Where("shop_id = ? AND user_id = ? AND revoked_at IS NULL", + shop.ID, user.ID). + Count(&activeCount).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 - } + if activeCount >= int64(totalQuota) { + return ErrDeviceLimitReached } sess := model.UserSession{ @@ -233,6 +233,9 @@ func (s *AuthService) Login(shopCode, username, password string, dev DeviceInfo) } return tx.Model(&model.User{}).Where("id = ?", user.ID).Update("last_login_at", now).Error }); err != nil { + if errors.Is(err, ErrDeviceLimitReached) { + s.recordLoginAttempt(shopCode, username, dev, false, "device_limit") + } return nil, nil, err } @@ -264,7 +267,35 @@ func (s *AuthService) recordLoginAttempt(shopCode, username string, dev DeviceIn } } -// effectiveQuota 返回某店某平台类的有效并发配额:优先每店 session_policy 覆盖,否则全局默认。 +// effectiveTotalQuota 返回某店「单用户跨全部平台」的有效并发总配额:优先每店 +// session_policy["total"] 覆盖,否则全局默认 config.C.Session.LimitTotal。至少为 1, +// 避免误配 0 时把所有会话踢光。 +func (s *AuthService) effectiveTotalQuota(shop *model.Shop) int { + q := config.C.Session.LimitTotal + if shop.CustomFields != nil { + if raw, ok := shop.CustomFields["session_policy"]; ok { + if policy, ok := raw.(map[string]interface{}); ok { + switch n := policy["total"].(type) { + case float64: + if int(n) > 0 { + q = int(n) + } + case int: + if n > 0 { + q = n + } + } + } + } + } + if q < 1 { + q = 1 + } + return q +} + +// effectiveQuota 返回某店某平台类的平台闸门值:>0=允许该平台,0=禁止。优先每店 +// session_policy 覆盖,否则全局默认。注意:数值大小不再作分平台并发上限(已统一为总数)。 func (s *AuthService) effectiveQuota(shop *model.Shop, pclass string) int { def := map[string]int{ "desktop": config.C.Session.LimitDesktop, diff --git a/backend/internal/service/session_test.go b/backend/internal/service/session_test.go index fb7ffa8..e14b3f7 100644 --- a/backend/internal/service/session_test.go +++ b/backend/internal/service/session_test.go @@ -11,9 +11,13 @@ import ( "github.com/wangjia/jiu/backend/testutil" ) -// 同平台类超配额时踢掉最旧会话。 -func TestLogin_PerClassQuota_KicksOldest(t *testing.T) { +// 达到总配额时拒绝新登录(不踢最旧),已有会话保持在线。 +func TestLogin_TotalQuota_RejectsWhenFull(t *testing.T) { db := testutil.SetupTestDB() + old := config.C.Session.LimitTotal + config.C.Session.LimitTotal = 2 + defer func() { config.C.Session.LimitTotal = old }() + shop := testutil.CreateTestShop(db, "SESS01") testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin") svc := NewAuthService(db) @@ -23,38 +27,43 @@ func TestLogin_PerClassQuota_KicksOldest(t *testing.T) { require.NoError(t, err) p2, _, err := svc.Login("SESS01", "admin", "password123", dev) require.NoError(t, err) - // 配额 2,第 3 次桌面端登录应踢掉最旧(p1) + // 总配额 2 已满,第 3 次登录应被拒绝 _, _, err = svc.Login("SESS01", "admin", "password123", dev) - require.NoError(t, err) + assert.ErrorIs(t, err, ErrDeviceLimitReached) - // 共 2 个未撤销会话 + // 仍为 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 仍可续期 + assert.NoError(t, err) _, err = svc.RefreshTokens(p2.RefreshToken) assert.NoError(t, err) } -// 移动端与桌面端配额互不影响。 -func TestLogin_QuotaIsolatedByClass(t *testing.T) { +// 总配额跨全部平台合并计数:不同平台类共用一个总上限,满额即拒。 +func TestLogin_TotalQuota_CountsAcrossClasses(t *testing.T) { db := testutil.SetupTestDB() + old := config.C.Session.LimitTotal + config.C.Session.LimitTotal = 2 + defer func() { config.C.Session.LimitTotal = old }() + shop := testutil.CreateTestShop(db, "SESS02") testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin") svc := NewAuthService(db) + // 桌面 + 移动 占满总配额 2 _, _, 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) + // 第三个不同平台(web)应被总配额拒绝 + _, _, err = svc.Login("SESS02", "admin", "password123", DeviceInfo{Platform: "web"}) + assert.ErrorIs(t, err, ErrDeviceLimitReached) 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,都没被踢 + assert.Equal(t, int64(2), active) // 仅前两个在线 } // 配额为 0 的平台拒绝登录(如禁 web)。 @@ -72,24 +81,23 @@ func TestLogin_PlatformDisabled(t *testing.T) { assert.ErrorIs(t, err, ErrPlatformNotAllowed) } -// 每店 session_policy 覆盖全局默认。 +// 每店 session_policy["total"] 覆盖全局总配额默认。 func TestLogin_PerShopPolicyOverride(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SESS04") - // 该店限桌面 1 + // 该店总配额限 1 db.Model(&model.Shop{}).Where("id = ?", shop.ID). - Update("custom_fields", model.JSON{"session_policy": map[string]interface{}{"desktop": float64(1)}}) + Update("custom_fields", model.JSON{"session_policy": map[string]interface{}{"total": 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) + // 总配额 1 已满,第 2 次登录(不同平台)应被拒绝,p1 保持在线 + _, _, err = svc.Login("SESS04", "admin", "password123", DeviceInfo{Platform: "android"}) + assert.ErrorIs(t, err, ErrDeviceLimitReached) _, err = svc.RefreshTokens(p1.RefreshToken) - assert.ErrorIs(t, err, ErrSessionRevoked) + assert.NoError(t, err) } // 被禁用用户无法用 refresh token 续命(修复历史漏洞)。 diff --git a/backend/testutil/setup.go b/backend/testutil/setup.go index 8be9910..9aceae5 100644 --- a/backend/testutil/setup.go +++ b/backend/testutil/setup.go @@ -28,6 +28,7 @@ func InitConfig() { RefreshExpireH: 168, }, Session: config.SessionConfig{ + LimitTotal: 5, LimitDesktop: 2, LimitMobile: 2, LimitWeb: 2,