chore: release server-v1.0.56
Deploy Server / release-deploy-server (push) Successful in 40s

会话/设备管理后端:user_sessions 会话表、JWT 加 sid 校验、按平台类限并发登录、
登录失败锁定、修复禁用账号仍可凭 refresh 续期漏洞、/auth/ping、/auth/logout、
GET /sessions、DELETE /sessions/:id(管理员强制下线)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-17 23:12:13 +08:00
parent 7c82553594
commit 53fa259284
16 changed files with 720 additions and 32 deletions
+265 -5
View File
@@ -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),
+6 -6
View File
@@ -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)
+169
View File
@@ -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)
}