diff --git a/backend/config/config.go b/backend/config/config.go index 5b7608f..5074b20 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -45,8 +45,9 @@ 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"` // 锁定时长(分钟) + MaxFailures int `mapstructure:"max_failures"` // 连续登录失败几次后锁定 + LockMinutes int `mapstructure:"lock_minutes"` // 锁定时长(分钟) + RetentionDays int `mapstructure:"retention_days"` // 已撤销/过期会话与失败登录记录的保留天数,过期后台清理 } type StorageConfig struct { @@ -90,6 +91,7 @@ func Load() { viper.SetDefault("session.limit_web", 2) // 默认不禁 web(官网挂着 Web 版 app);设 0 可禁 viper.SetDefault("session.max_failures", 5) viper.SetDefault("session.lock_minutes", 15) + viper.SetDefault("session.retention_days", 90) 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 a20ae09..48331fa 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -11,11 +11,12 @@ import ( ) type AuthHandler struct { - svc *service.AuthService + svc *service.AuthService + licSvc *service.LicenseService // 用于心跳 /auth/ping 回带授权概况;测试构造可传 nil } -func NewAuthHandler(svc *service.AuthService) *AuthHandler { - return &AuthHandler{svc: svc} +func NewAuthHandler(svc *service.AuthService, licSvc *service.LicenseService) *AuthHandler { + return &AuthHandler{svc: svc, licSvc: licSvc} } // Login POST /api/v1/auth/login @@ -118,7 +119,20 @@ func (h *AuthHandler) Logout(c *gin.Context) { util.RespondSuccess(c, gin.H{"ok": true}) } -// Ping POST /api/v1/auth/ping —— 心跳,仅触发中间件刷新 last_seen;会话已撤销则中间件直接 401 +// Ping POST /api/v1/auth/ping —— 心跳:会话已撤销则中间件直接 401(触发登出), +// 否则刷新 last_seen 并**回带当前授权概况**,使客户端无需再单独轮询 /license/info +// (到期/续费/被改动等变化随心跳即时反映到横幅/只读门禁)。 func (h *AuthHandler) Ping(c *gin.Context) { - util.RespondSuccess(c, gin.H{"ok": true}) + if h.licSvc == nil { + util.RespondSuccess(c, gin.H{"ok": true}) + return + } + view, err := h.licSvc.ShopInfoView(middleware.GetShopID(c)) + if err != nil { + // 授权查询出错不应让心跳失败(会话本身有效):省略 license 字段,客户端保留上次状态。 + util.RespondSuccess(c, gin.H{"ok": true}) + return + } + // view 可能为 nil(确无授权)→ license:null,与 /license/info 的 data:null 语义一致。 + util.RespondSuccess(c, gin.H{"ok": true, "license": view}) } diff --git a/backend/internal/handler/auth_test.go b/backend/internal/handler/auth_test.go index 9c433d2..5bd776e 100644 --- a/backend/internal/handler/auth_test.go +++ b/backend/internal/handler/auth_test.go @@ -11,7 +11,10 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/wangjia/jiu/backend/config" + "github.com/wangjia/jiu/backend/internal/middleware" "github.com/wangjia/jiu/backend/internal/service" + "github.com/wangjia/jiu/backend/internal/util" "github.com/wangjia/jiu/backend/testutil" ) @@ -29,7 +32,7 @@ func newTestAuthRouter(t *testing.T) (*gin.Engine, *gin.Engine) { db.Exec("UPDATE users SET is_active = 0 WHERE username = 'disabled' AND shop_id = ?", shop.ID) svc := service.NewAuthService(db) - h := NewAuthHandler(svc) + h := NewAuthHandler(svc, nil) r := gin.New() r.POST("/api/v1/auth/login", h.Login) @@ -43,7 +46,7 @@ func TestAuthHandler_Login_Success(t *testing.T) { testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin") svc := service.NewAuthService(db) - h := NewAuthHandler(svc) + h := NewAuthHandler(svc, nil) r := gin.New() r.POST("/api/v1/auth/login", h.Login) @@ -74,7 +77,7 @@ func TestAuthHandler_Login_WrongPassword(t *testing.T) { testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin") svc := service.NewAuthService(db) - h := NewAuthHandler(svc) + h := NewAuthHandler(svc, nil) r := gin.New() r.POST("/api/v1/auth/login", h.Login) @@ -96,7 +99,7 @@ func TestAuthHandler_Login_WrongPassword(t *testing.T) { func TestAuthHandler_Login_MissingFields(t *testing.T) { db := testutil.SetupTestDB() svc := service.NewAuthService(db) - h := NewAuthHandler(svc) + h := NewAuthHandler(svc, nil) r := gin.New() r.POST("/api/v1/auth/login", h.Login) @@ -120,7 +123,7 @@ func TestAuthHandler_Refresh_Success(t *testing.T) { testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin") svc := service.NewAuthService(db) - h := NewAuthHandler(svc) + h := NewAuthHandler(svc, nil) r := gin.New() r.POST("/api/v1/auth/login", h.Login) r.POST("/api/v1/auth/refresh", h.Refresh) @@ -160,7 +163,7 @@ func TestAuthHandler_Refresh_Success(t *testing.T) { func TestAuthHandler_Refresh_InvalidToken(t *testing.T) { db := testutil.SetupTestDB() svc := service.NewAuthService(db) - h := NewAuthHandler(svc) + h := NewAuthHandler(svc, nil) r := gin.New() r.POST("/api/v1/auth/refresh", h.Refresh) @@ -181,7 +184,7 @@ func TestAuthHandler_Login_DisabledUser(t *testing.T) { db.Model(user).Update("is_active", false) svc := service.NewAuthService(db) - h := NewAuthHandler(svc) + h := NewAuthHandler(svc, nil) r := gin.New() r.POST("/api/v1/auth/login", h.Login) @@ -205,7 +208,7 @@ func TestAuthHandler_Login_WrongShopCode(t *testing.T) { testutil.CreateTestShop(db, "AH006") svc := service.NewAuthService(db) - h := NewAuthHandler(svc) + h := NewAuthHandler(svc, nil) r := gin.New() r.POST("/api/v1/auth/login", h.Login) @@ -227,7 +230,7 @@ func TestAuthHandler_Login_WrongShopCode(t *testing.T) { func TestAuthHandler_Login_EmptyBody(t *testing.T) { db := testutil.SetupTestDB() svc := service.NewAuthService(db) - h := NewAuthHandler(svc) + h := NewAuthHandler(svc, nil) r := gin.New() r.POST("/api/v1/auth/login", h.Login) @@ -242,7 +245,7 @@ func TestAuthHandler_Login_EmptyBody(t *testing.T) { func TestAuthHandler_Refresh_MissingToken(t *testing.T) { db := testutil.SetupTestDB() svc := service.NewAuthService(db) - h := NewAuthHandler(svc) + h := NewAuthHandler(svc, nil) r := gin.New() r.POST("/api/v1/auth/refresh", h.Refresh) @@ -263,7 +266,7 @@ func TestAuthHandler_Login_ResponseContainsUserInfo(t *testing.T) { testutil.CreateTestUser(db, shop.ID, "manager", "password123", "admin") svc := service.NewAuthService(db) - h := NewAuthHandler(svc) + h := NewAuthHandler(svc, nil) r := gin.New() r.POST("/api/v1/auth/login", h.Login) @@ -294,3 +297,42 @@ func TestAuthHandler_Login_ResponseContainsUserInfo(t *testing.T) { assert.Equal(t, "manager", userInfo["username"]) } } + +// #8 心跳 /auth/ping 回带授权概况,客户端据此免去单独轮询 /license/info。 +func TestAuthHandler_Ping_ReturnsLicense(t *testing.T) { + db := testutil.SetupTestDB() + priv, _, err := util.GenerateEd25519KeyPair() + require.NoError(t, err) + config.C.License.Ed25519PrivateKey = priv + + shop := testutil.CreateTestShop(db, "AHPING") + testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin") + + authSvc := service.NewAuthService(db) + // 登录触发首登自动 trial。 + _, _, err = authSvc.Login("AHPING", "admin", "password123", service.DeviceInfo{Platform: "windows"}) + require.NoError(t, err) + + licSvc := service.NewLicenseService(db) + h := NewAuthHandler(authSvc, licSvc) + + r := gin.New() + r.POST("/api/v1/auth/ping", func(c *gin.Context) { + c.Set(middleware.CtxShopID, shop.ID) // 模拟 JWT 中间件注入 shop_id + h.Ping(c) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/v1/auth/ping", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + data := resp["data"].(map[string]interface{}) + assert.Equal(t, true, data["ok"]) + lic, ok := data["license"].(map[string]interface{}) + require.True(t, ok, "心跳应回带 license 概况") + assert.Equal(t, "trial", lic["type"]) + assert.NotEmpty(t, lic["phase"]) +} diff --git a/backend/internal/handler/license.go b/backend/internal/handler/license.go index bc4be82..810c567 100644 --- a/backend/internal/handler/license.go +++ b/backend/internal/handler/license.go @@ -58,29 +58,13 @@ func (h *LicenseHandler) Verify(c *gin.Context) { // Info GET /api/v1/license/info — 当前门店授权概况 func (h *LicenseHandler) Info(c *gin.Context) { - shopID := middleware.GetShopID(c) - lic, err := h.svc.ShopInfo(shopID) - if err != nil { - util.RespondSuccess(c, nil) - return - } - - deviceCount, err := h.svc.CountDevices(lic.ID) + view, err := h.svc.ShopInfoView(middleware.GetShopID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - - phase := middleware.CalcLicensePhase(lic.ExpiresAt) - c.JSON(http.StatusOK, gin.H{"data": gin.H{ - "id": lic.ID, - "type": lic.Type, - "is_active": lic.IsActive, - "max_devices": lic.MaxDevices, - "device_count": deviceCount, - "expires_at": lic.ExpiresAt, - "phase": phase, - }}) + // view 为 nil 表示无有效授权 → data:null(与原行为一致)。 + util.RespondSuccess(c, view) } // Devices GET /api/v1/license/devices — 已绑定设备列表 diff --git a/backend/internal/handler/session.go b/backend/internal/handler/session.go index 89cc99b..288c0be 100644 --- a/backend/internal/handler/session.go +++ b/backend/internal/handler/session.go @@ -40,7 +40,7 @@ func (h *SessionHandler) ForceLogout(c *gin.Context) { return } shopID := middleware.GetShopID(c) - if err := h.svc.ForceLogout(shopID, id); err != nil { + if err := h.svc.ForceLogout(shopID, id, middleware.GetUserID(c)); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) return diff --git a/backend/internal/handler/session_security_test.go b/backend/internal/handler/session_security_test.go new file mode 100644 index 0000000..c38cfde --- /dev/null +++ b/backend/internal/handler/session_security_test.go @@ -0,0 +1,118 @@ +package handler + +import ( + "net/http" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + + "github.com/wangjia/jiu/backend/internal/middleware" + "github.com/wangjia/jiu/backend/internal/model" + "github.com/wangjia/jiu/backend/internal/service" + "github.com/wangjia/jiu/backend/testutil" +) + +// setupSessionSecurityRouter 挂 JWT 中间件 + 用户管理路由 + 一个受保护 GET, +// 用于验证「改密/禁用即时下线」与中间件对 is_active 的兜底校验。 +func setupSessionSecurityRouter(db *gorm.DB) *gin.Engine { + userH := NewUserHandler(db) + r := gin.New() + r.Use(gin.Recovery()) + api := r.Group("/api/v1") + api.Use(middleware.JWT(db)) + api.GET("/ping", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) }) + users := api.Group("/users") + users.PUT("/:id", userH.Update) + users.PUT("/:id/reset-password", userH.ResetPassword) + return r +} + +// #2 管理员重置某用户密码 → 该用户全部活跃会话立即失效。 +func TestResetPassword_RevokesUserSessions(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "SEC01") + admin := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin") + target := testutil.CreateTestUser(db, shop.ID, "clerk", "password123", "operator") + r := setupSessionSecurityRouter(db) + svc := service.NewAuthService(db) + + // 目标用户登录,拿到带 sid 的 access token(建出真实会话行)。 + pair, _, err := svc.Login("SEC01", "clerk", "password123", service.DeviceInfo{Platform: "windows"}) + require.NoError(t, err) + targetToken := pair.AccessToken + + // 改密前:目标 token 可用。 + require.Equal(t, http.StatusOK, makeRequest(r, "GET", "/api/v1/ping", targetToken, nil).Code) + + // 管理员重置目标用户密码。 + adminToken := getAuthToken(admin.ID, shop.ID, "admin") + w := makeRequest(r, "PUT", "/api/v1/users/2/reset-password", adminToken, jsonBody("password", "newpass123")) + require.Equal(t, http.StatusOK, w.Code) + + // 改密后:目标 token 立即 401 SESSION_REVOKED。 + w = makeRequest(r, "GET", "/api/v1/ping", targetToken, nil) + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Equal(t, "SESSION_REVOKED", parseResponse(w)["code"]) + + // 会话审计字段。 + var sess model.UserSession + require.NoError(t, db.Where("user_id = ?", target.ID).First(&sess).Error) + assert.Equal(t, "pwd_reset", sess.RevokedReason) + require.NotNil(t, sess.RevokedBy) + assert.Equal(t, admin.ID, *sess.RevokedBy) +} + +// #3 管理员停用某用户 → 该用户活跃会话被吊销,原 token 立即 401。 +func TestDisableUser_RevokesSessions(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "SEC02") + admin := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin") + target := testutil.CreateTestUser(db, shop.ID, "clerk", "password123", "operator") + r := setupSessionSecurityRouter(db) + svc := service.NewAuthService(db) + + pair, _, err := svc.Login("SEC02", "clerk", "password123", service.DeviceInfo{Platform: "windows"}) + require.NoError(t, err) + targetToken := pair.AccessToken + + adminToken := getAuthToken(admin.ID, shop.ID, "admin") + w := makeRequest(r, "PUT", "/api/v1/users/2", adminToken, jsonBody("is_active", false)) + require.Equal(t, http.StatusOK, w.Code) + + w = makeRequest(r, "GET", "/api/v1/ping", targetToken, nil) + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Equal(t, "SESSION_REVOKED", parseResponse(w)["code"]) + + var sess model.UserSession + require.NoError(t, db.Where("user_id = ?", target.ID).First(&sess).Error) + assert.Equal(t, "disabled", sess.RevokedReason) +} + +// #3 兜底:直接改库把 is_active=0(不动会话)→ 中间件 JOIN 校验返回 401 USER_DISABLED。 +func TestMiddleware_RejectsDisabledUserOnDirectDBFlip(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "SEC03") + target := testutil.CreateTestUser(db, shop.ID, "clerk", "password123", "operator") + r := setupSessionSecurityRouter(db) + svc := service.NewAuthService(db) + + pair, _, err := svc.Login("SEC03", "clerk", "password123", service.DeviceInfo{Platform: "windows"}) + require.NoError(t, err) + targetToken := pair.AccessToken + require.Equal(t, http.StatusOK, makeRequest(r, "GET", "/api/v1/ping", targetToken, nil).Code) + + // 绕过 handler 直接改库:会话仍为活跃(revoked_at 为空),仅 is_active=0。 + require.NoError(t, db.Exec("UPDATE users SET is_active = 0 WHERE id = ?", target.ID).Error) + + w := makeRequest(r, "GET", "/api/v1/ping", targetToken, nil) + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Equal(t, "USER_DISABLED", parseResponse(w)["code"]) + + // 会话本身未被吊销,证明拦截来自中间件对 is_active 的兜底校验。 + var sess model.UserSession + require.NoError(t, db.Where("user_id = ?", target.ID).First(&sess).Error) + assert.Nil(t, sess.RevokedAt) +} diff --git a/backend/internal/handler/user.go b/backend/internal/handler/user.go index 5f4ab7c..1b6c9c3 100644 --- a/backend/internal/handler/user.go +++ b/backend/internal/handler/user.go @@ -9,6 +9,7 @@ import ( "github.com/wangjia/jiu/backend/internal/middleware" "github.com/wangjia/jiu/backend/internal/model" + "github.com/wangjia/jiu/backend/internal/service" "github.com/wangjia/jiu/backend/internal/util" ) @@ -100,6 +101,13 @@ func (h *UserHandler) Update(c *gin.Context) { if len(updates) > 0 { h.db.Model(&u).Where("shop_id = ?", shopID).Updates(updates) } + // 禁用用户时立即吊销其全部活跃会话,使其在线设备下次请求即被拦下。 + if req.IsActive != nil && !*req.IsActive { + if err := service.RevokeUserSessions(h.db, shopID, u.ID, middleware.GetUserID(c), "disabled"); err != nil { + // 吊销失败不阻断本次更新(用户已停用,refresh 也已被 is_active 拦截)。 + c.Error(err) //nolint:errcheck + } + } h.db.Where("id = ? AND shop_id = ?", u.ID, shopID).First(&u) util.RespondSuccess(c, u) } @@ -126,5 +134,9 @@ func (h *UserHandler) ResetPassword(c *gin.Context) { return } h.db.Model(&u).Update("password_hash", string(hash)) + // 改密后吊销该用户全部活跃会话,旧设备 token 立即失效(防止凭旧凭据继续访问)。 + if err := service.RevokeUserSessions(h.db, shopID, u.ID, middleware.GetUserID(c), "pwd_reset"); err != nil { + c.Error(err) //nolint:errcheck + } c.JSON(http.StatusOK, gin.H{"message": "密码已重置"}) } diff --git a/backend/internal/handler/write_access_test.go b/backend/internal/handler/write_access_test.go new file mode 100644 index 0000000..283bb2d --- /dev/null +++ b/backend/internal/handler/write_access_test.go @@ -0,0 +1,106 @@ +package handler + +import ( + "fmt" + "net/http" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "gorm.io/gorm" + + "github.com/wangjia/jiu/backend/internal/middleware" + "github.com/wangjia/jiu/backend/internal/model" + "github.com/wangjia/jiu/backend/testutil" +) + +// setupGuardedRouter 复刻 router.go:98 的真实链路: +// JWT → ReadOnly → LicenseGuard,后接真实 product handler。 +// 用于「写权限鉴权」的全链路集成测试(后端侧前后端契约)。 +func setupGuardedRouter(db *gorm.DB) *gin.Engine { + productH := NewProductHandler(db) + + r := gin.New() + r.Use(gin.Recovery()) + + api := r.Group("/api/v1") + api.Use(middleware.JWT(db), middleware.ReadOnly(), middleware.LicenseGuard(db)) + + products := api.Group("/products") + products.GET("", productH.List) + products.POST("", productH.Create) + + return r +} + +// seedLicense 给店铺写入一条有效授权,daysAgo>0 表示已过期天数,<=0 表示未过期(剩余天数)。 +func seedLicense(t *testing.T, db *gorm.DB, shopID uint64, daysAgo int) { + exp := time.Now().Add(-time.Duration(daysAgo) * 24 * time.Hour) + assert.NoError(t, db.Create(&model.License{ + ShopID: shopID, + IsActive: true, + Type: "trial", + LicenseKey: fmt.Sprintf("KEY-%d-%d", shopID, time.Now().UnixNano()), + ExpiresAt: &exp, + }).Error) +} + +// TestWriteAccessMatrix 钉死「角色 × 授权阶段」对写/读操作的 403/200 契约。 +// 这是前端唯一依赖的契约,任何回归都会被这张表抓到。 +func TestWriteAccessMatrix(t *testing.T) { + gin.SetMode(gin.TestMode) + db := testutil.SetupTestDB() + r := setupGuardedRouter(db) + + // 每档一个独立店铺,避免 LicenseGuard 的 30s 缓存按 shopID 串扰。 + type tc struct { + name string + role string + daysExpired int // >0 已过期天数;-30 表示未过期 + wantPostFwd bool // POST 是否应放行(非 403) + wantGetFwd bool // GET 是否应放行 + wantCode string // 期望 403 body 的 code(空则不校验) + wantPhase string // 期望 403 body 的 phase(空则不校验) + } + cases := []tc{ + {"operator_normal", "operator", -30, true, true, "", ""}, + {"operator_grace", "operator", 3, true, true, "", ""}, + {"operator_readonly", "operator", 10, false, true, "", "readonly"}, + {"operator_locked", "operator", 20, false, false, "", "locked"}, + {"readonly_role", "readonly", -30, false, true, "READONLY_USER", ""}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + shop := testutil.CreateTestShop(db, c.name) + shopID := shop.ID + user := testutil.CreateTestUser(db, shopID, "u_"+c.name, "password123", c.role) + seedLicense(t, db, shopID, c.daysExpired) + token := getAuthToken(user.ID, shopID, c.role) + + // POST /products + wPost := makeRequest(r, http.MethodPost, "/api/v1/products", token, jsonBody("name", "集成测试酒")) + if c.wantPostFwd { + assert.NotEqual(t, http.StatusForbidden, wPost.Code, "POST 应放行") + } else { + assert.Equal(t, http.StatusForbidden, wPost.Code, "POST 应被拦截") + body := parseResponse(wPost) + if c.wantCode != "" { + assert.Equal(t, c.wantCode, body["code"]) + } + if c.wantPhase != "" { + assert.Equal(t, c.wantPhase, body["phase"]) + } + } + + // GET /products + wGet := makeRequest(r, http.MethodGet, "/api/v1/products", token, nil) + if c.wantGetFwd { + assert.NotEqual(t, http.StatusForbidden, wGet.Code, "GET 应放行") + } else { + assert.Equal(t, http.StatusForbidden, wGet.Code, "GET 应被拦截") + } + }) + } +} diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index a12a7fe..22728fd 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -54,14 +54,27 @@ func JWT(db *gorm.DB) gin.HandlerFunc { // 会话校验:带 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 { + // 一次查询同时取会话 + 用户启用状态:兜底「直接改库 is_active=0」也能即时下线。 + var row struct { + model.UserSession + IsActive bool + DeletedAt *time.Time + } + err := db.Table("user_sessions AS s"). + Select("s.*, u.is_active AS is_active, u.deleted_at AS deleted_at"). + Joins("LEFT JOIN users u ON u.id = s.user_id"). + Where("s.sid = ?", claims.SID).First(&row).Error + if err != nil || row.UserSession.RevokedAt != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session revoked", "code": "SESSION_REVOKED"}) return } + if !row.IsActive || row.DeletedAt != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user disabled", "code": "USER_DISABLED"}) + return + } // 节流刷新 last_seen,用于在线状态判定 - if time.Since(sess.LastSeenAt) > lastSeenThrottle { - db.Model(&model.UserSession{}).Where("id = ?", sess.ID). + if time.Since(row.UserSession.LastSeenAt) > lastSeenThrottle { + db.Model(&model.UserSession{}).Where("id = ?", row.UserSession.ID). Update("last_seen_at", time.Now()) } } @@ -106,7 +119,11 @@ func ReadOnly() gin.HandlerFunc { return func(c *gin.Context) { role, _ := c.Get(CtxRole) if role == "readonly" && c.Request.Method != "GET" { - c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "readonly user"}) + // code 供前端区分「角色只读」与「授权过期」(后者由 LicenseGuard 返回 phase) + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "readonly user", + "code": "READONLY_USER", + }) return } c.Next() diff --git a/backend/internal/middleware/license_guard.go b/backend/internal/middleware/license_guard.go index 251e215..0a8a70a 100644 --- a/backend/internal/middleware/license_guard.go +++ b/backend/internal/middleware/license_guard.go @@ -2,9 +2,13 @@ package middleware import ( "net/http" + "sync" "time" "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "github.com/wangjia/jiu/backend/internal/model" ) const ( @@ -38,7 +42,8 @@ func CalcLicensePhase(expiresAt *time.Time) string { return PhaseLocked } -// GetLicensePhase returns the current phase for the authenticated request. +// GetLicensePhase returns the phase derived from the JWT claim (login-time snapshot). +// Kept for display/diagnostic use; enforcement uses the live DB phase (see LicenseGuard). func GetLicensePhase(c *gin.Context) string { v, _ := c.Get(CtxLicenseExpiresAt) ptr, _ := v.(*int64) @@ -49,12 +54,76 @@ func GetLicensePhase(c *gin.Context) string { return CalcLicensePhase(&t) } +// licensePhaseCacheTTL 控制实时 phase 查库的缓存时长: +// 既避免每个写请求都查库,又保证 DB 改动(续费/过期/被改)在 TTL 内生效。 +// 与前端心跳(30s)同量级,用户感知一致。 +const licensePhaseCacheTTL = 30 * time.Second + +type licensePhaseEntry struct { + expiresAt *time.Time // nil = 永久授权 / 无有效授权(按 normal 处理见下) + hasActive bool // 是否存在有效授权记录 + revoked bool // 曾配置授权但当前全部被停用/吊销(is_active=0)→ 锁定写操作 + fetchedAt time.Time +} + +var licensePhaseCache sync.Map // shopID(uint64) -> licensePhaseEntry + +// InvalidateLicensePhase 清除某店的 phase 缓存,使授权变更(激活/续费/停用)即时生效, +// 不必等 30s TTL 自然过期。授权服务在激活/签发后调用。 +func InvalidateLicensePhase(shopID uint64) { + licensePhaseCache.Delete(shopID) +} + +// liveLicensePhase 按当前 DB 的有效授权实时计算 phase(带 30s 每店缓存)。 +// - 有有效授权:按其 expires_at 计算 phase。 +// - 曾有授权但当前全部被停用(is_active=0):视为被吊销 → 锁定(管理员主动收回授权即时生效)。 +// - 从未配置任何授权(如未启用授权体系/试用签发失败):回退到 token 中的 lic_exp 快照,避免误锁。 +func liveLicensePhase(db *gorm.DB, c *gin.Context) string { + shopID := GetShopID(c) + + var entry licensePhaseEntry + if v, ok := licensePhaseCache.Load(shopID); ok { + entry = v.(licensePhaseEntry) + } + if entry.fetchedAt.IsZero() || time.Since(entry.fetchedAt) > licensePhaseCacheTTL { + var lic model.License + // 必须与 LicenseService.ShopInfo(/license/info 展示)一致: + // 取最新创建(id DESC)的有效授权,否则展示与拦截可能选到不同记录, + // 出现「展示已过期但仍可写」。 + err := db.Where("shop_id = ? AND is_active = ?", shopID, true). + Order("id DESC").First(&lic).Error + if err == nil { + entry = licensePhaseEntry{expiresAt: lic.ExpiresAt, hasActive: true, fetchedAt: time.Now()} + } else { + // 无有效授权:区分「曾配置过但被停用/吊销」与「从未配置」。 + // 过期授权 is_active 仍为 1(走上面分支按 phase 降级),故此处无 active 记录 + // 只可能是管理员主动停用(is_active=0)或确实未配置。 + var anyCount int64 + db.Model(&model.License{}).Where("shop_id = ?", shopID).Count(&anyCount) + entry = licensePhaseEntry{hasActive: false, revoked: anyCount > 0, fetchedAt: time.Now()} + } + licensePhaseCache.Store(shopID, entry) + } + + if entry.revoked { + // 曾有授权但当前全部被停用 → 收回写权限,与「锁定」一致。 + return PhaseLocked + } + if !entry.hasActive { + // 从未配置有效授权:回退到登录时 token 里的快照,避免误判 + return GetLicensePhase(c) + } + return CalcLicensePhase(entry.expiresAt) +} + // LicenseGuard blocks write operations when the shop's license is expired (readonly/locked). +// 以**当前 DB** 的授权状态实时判定 phase(带 30s 缓存),而非信任登录时嵌入 token 的快照, +// 这样续费/过期/被改动后无需重登即可在 ~30s 内对写操作生效。 // License routes (/license/*) must be mounted outside this middleware so users can // view status and activate a new key even when locked. -func LicenseGuard() gin.HandlerFunc { +func LicenseGuard(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { - phase := GetLicensePhase(c) + phase := liveLicensePhase(db, c) switch phase { case PhaseLocked: c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ diff --git a/backend/internal/middleware/license_guard_test.go b/backend/internal/middleware/license_guard_test.go index 32a4f23..97ab7f2 100644 --- a/backend/internal/middleware/license_guard_test.go +++ b/backend/internal/middleware/license_guard_test.go @@ -1,10 +1,18 @@ package middleware import ( + "net/http" + "net/http/httptest" "testing" "time" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/wangjia/jiu/backend/internal/model" ) func TestCalcLicensePhase(t *testing.T) { @@ -37,3 +45,135 @@ func TestCalcLicensePhase(t *testing.T) { locked := now.Add(-16 * 24 * time.Hour) assert.Equal(t, PhaseLocked, CalcLicensePhase(&locked)) } + +// TestLicenseGuardUsesLiveDBPhase 验证 LicenseGuard 以「当前 DB 的授权状态」判 phase, +// 而非信任登录时嵌入 token 的快照(lic_exp)。这是「只读模式还是能改数据」的根因修复: +// 即便 token 快照仍是 normal(lic_exp 未来),DB 改成过期后写操作也应立即被拦截。 +func TestLicenseGuardUsesLiveDBPhase(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + assert.NoError(t, err) + // sqlite 不支持 enum/json 列类型,AutoMigrate 会失败,按 testutil 方式用原始 SQL 建表。 + assert.NoError(t, db.Exec(`CREATE TABLE licenses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME, updated_at DATETIME, deleted_at DATETIME, + shop_id INTEGER NOT NULL, + license_key TEXT, type TEXT, expires_at DATETIME, + is_active INTEGER DEFAULT 1, max_devices INTEGER DEFAULT 3, + features TEXT, device_id TEXT, activated_at DATETIME + )`).Error) + now := time.Now() + tokenFuture := now.Add(30 * 24 * time.Hour).Unix() // token 快照恒为 normal + + // 每个 shop 一条有效授权,DB 到期时间各异;用不同 shopID 规避缓存串扰。 + seed := func(shopID uint64, dbExpiresAt *time.Time) { + licensePhaseCache.Delete(shopID) + assert.NoError(t, db.Create(&model.License{ + ShopID: shopID, IsActive: true, ExpiresAt: dbExpiresAt, + }).Error) + } + + // invoke 跑一次带 LicenseGuard 的请求,返回(是否被拦截, 状态码)。 + invoke := func(shopID uint64, method string) (bool, int) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(method, "/products", nil) + c.Set(CtxShopID, shopID) + c.Set(CtxLicenseExpiresAt, &tokenFuture) + LicenseGuard(db)(c) + return c.IsAborted(), w.Code + } + + // DB 未过期 → 写放行 + future := now.Add(10 * 24 * time.Hour) + seed(1001, &future) + aborted, _ := invoke(1001, http.MethodPost) + assert.False(t, aborted, "DB 未过期应放行写操作") + + // DB 过期 10 天(只读期)→ 即便 token 说 normal,POST 也被 403 拦截 + expired10d := now.Add(-10 * 24 * time.Hour) + seed(1002, &expired10d) + aborted, code := invoke(1002, http.MethodPost) + assert.True(t, aborted, "DB 只读期应拦截写操作") + assert.Equal(t, http.StatusForbidden, code) + + // 只读期 GET 放行 + aborted, _ = invoke(1002, http.MethodGet) + assert.False(t, aborted, "只读期 GET 应放行") + + // DB 过期 20 天(锁定期)→ 连 GET 也 403 + expired20d := now.Add(-20 * 24 * time.Hour) + seed(1003, &expired20d) + aborted, code = invoke(1003, http.MethodGet) + assert.True(t, aborted, "锁定期应拦截所有请求") + assert.Equal(t, http.StatusForbidden, code) +} + +// TestLicenseGuardRevokedAndInvalidation 覆盖 #5/#6: +// - 主动停用(is_active=0,且未过期)应锁定写操作,而非回退到 token 快照继续放行; +// - 从未配置授权的门店仍回退 token 快照,避免误锁; +// - InvalidateLicensePhase 让授权变更绕过 30s 缓存即时生效。 +func TestLicenseGuardRevokedAndInvalidation(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + assert.NoError(t, err) + assert.NoError(t, db.Exec(`CREATE TABLE licenses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME, updated_at DATETIME, deleted_at DATETIME, + shop_id INTEGER NOT NULL, + license_key TEXT, type TEXT, expires_at DATETIME, + is_active INTEGER DEFAULT 1, max_devices INTEGER DEFAULT 3, + features TEXT, device_id TEXT, activated_at DATETIME + )`).Error) + + now := time.Now() + tokenFuture := now.Add(30 * 24 * time.Hour).Unix() // token 快照恒为 normal + + invoke := func(shopID uint64, method string) (bool, int) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(method, "/products", nil) + c.Set(CtxShopID, shopID) + c.Set(CtxLicenseExpiresAt, &tokenFuture) + LicenseGuard(db)(c) + return c.IsAborted(), w.Code + } + + // #6 主动停用:未过期但 is_active=0 → 即便 token 说 normal,写/读都应被锁定。 + // 注意 GORM bool 零值陷阱:Create 时 IsActive:false 会被列默认值 1 覆盖, + // 生产中停用也总是经 Update 落地,故这里同样建后再 Update。 + future := now.Add(10 * 24 * time.Hour) + InvalidateLicensePhase(2001) + lic2001 := model.License{ShopID: 2001, IsActive: true, ExpiresAt: &future} + assert.NoError(t, db.Create(&lic2001).Error) + assert.NoError(t, db.Model(&model.License{}).Where("id = ?", lic2001.ID).Update("is_active", false).Error) + aborted, code := invoke(2001, http.MethodGet) + assert.True(t, aborted, "被停用授权应锁定(连 GET 也拦)") + assert.Equal(t, http.StatusForbidden, code) + + // #6 从未配置授权:无任何 license 行 → 回退 token 快照(normal)→ 放行,避免误锁。 + InvalidateLicensePhase(2002) + aborted, _ = invoke(2002, http.MethodPost) + assert.False(t, aborted, "未配置授权的门店应回退 token 快照放行") + + // #5 缓存失效:先有有效授权(缓存为 normal 放行),再停用并 Invalidate → 立即锁定。 + InvalidateLicensePhase(2003) + lic := model.License{ShopID: 2003, IsActive: true, ExpiresAt: &future} + assert.NoError(t, db.Create(&lic).Error) + aborted, _ = invoke(2003, http.MethodPost) // 写入缓存 normal + assert.False(t, aborted, "有效授权应放行写操作") + + assert.NoError(t, db.Model(&model.License{}).Where("id = ?", lic.ID).Update("is_active", false).Error) + // 不失效缓存:30s 内仍按旧 normal 放行 + aborted, _ = invoke(2003, http.MethodPost) + assert.False(t, aborted, "未失效缓存时停用应仍受 30s 缓存保护") + // 失效缓存后:重新查库 → 锁定即时生效 + InvalidateLicensePhase(2003) + aborted, code = invoke(2003, http.MethodPost) + assert.True(t, aborted, "Invalidate 后停用应即时生效") + assert.Equal(t, http.StatusForbidden, code) +} diff --git a/backend/internal/middleware/readonly_test.go b/backend/internal/middleware/readonly_test.go new file mode 100644 index 0000000..3a29f75 --- /dev/null +++ b/backend/internal/middleware/readonly_test.go @@ -0,0 +1,46 @@ +package middleware + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +// TestReadOnly 验证只读角色的写操作被拦截、读操作放行,且 403 带机器可读 code。 +func TestReadOnly(t *testing.T) { + gin.SetMode(gin.TestMode) + + // invoke 跑一次带 ReadOnly 的请求,返回(是否被拦截, 状态码, body)。 + invoke := func(role, method string) (bool, int, map[string]any) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(method, "/products", nil) + c.Set(CtxRole, role) + ReadOnly()(c) + var body map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &body) + return c.IsAborted(), w.Code, body + } + + // 只读角色 + 写方法 → 403 + code=READONLY_USER + for _, m := range []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} { + aborted, code, body := invoke("readonly", m) + assert.True(t, aborted, "readonly 用户 %s 应被拦截", m) + assert.Equal(t, http.StatusForbidden, code) + assert.Equal(t, "READONLY_USER", body["code"], "%s 应返回 code=READONLY_USER", m) + } + + // 只读角色 + GET → 放行 + aborted, _, _ := invoke("readonly", http.MethodGet) + assert.False(t, aborted, "readonly 用户 GET 应放行") + + // 非只读角色 + 写方法 → 放行 + for _, role := range []string{"operator", "admin", "superadmin"} { + aborted, _, _ := invoke(role, http.MethodPost) + assert.False(t, aborted, "%s 用户写操作应放行", role) + } +} diff --git a/backend/internal/model/login_attempt.go b/backend/internal/model/login_attempt.go new file mode 100644 index 0000000..58a9a25 --- /dev/null +++ b/backend/internal/model/login_attempt.go @@ -0,0 +1,18 @@ +package model + +import "time" + +// LoginAttempt 登录尝试审计。当前只记录失败尝试(成功登录已由 user_sessions +// + users.last_login_at 覆盖),用于风控/审计排查异常登录。由保留期清理任务定期删除旧行。 +type LoginAttempt struct { + ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` + ShopCode string `gorm:"size:64;index:idx_attempt_user" json:"shop_code"` + Username string `gorm:"size:50;index:idx_attempt_user" json:"username"` + IP string `gorm:"size:64;index:idx_attempt_ip" json:"ip"` + UserAgent string `gorm:"size:512" json:"user_agent"` + Success bool `gorm:"default:false" json:"success"` + Reason string `gorm:"size:40" json:"reason"` // invalid_shop|invalid_user|bad_password|locked|inactive|platform_not_allowed + CreatedAt time.Time `gorm:"autoCreateTime;index:idx_attempt_user;index:idx_attempt_ip" json:"created_at"` +} + +func (LoginAttempt) TableName() string { return "login_attempts" } diff --git a/backend/internal/model/user_session.go b/backend/internal/model/user_session.go index 308196d..c22af55 100644 --- a/backend/internal/model/user_session.go +++ b/backend/internal/model/user_session.go @@ -15,10 +15,17 @@ type UserSession struct { 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"` + // RefreshJTI 当前有效 refresh token 的 jti,用于「轮换 + 重用检测」: + // 每次续期轮换此值,若 refresh 携带的 jti 与之不符即判定为旧 token 重放(盗用),吊销整条会话。 + RefreshJTI string `gorm:"column:refresh_jti;size:64" json:"-"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + // LastSeenAt 手工维护(Login 建行赋值、心跳/refresh 显式 Update)。 + // 故意用 autoCreateTime(建行给默认、之后不被 ORM 自动改);切勿改成 autoUpdateTime, + // 否则 revoke/cleanup 等任意 Updates 都会把已撤销会话误刷成「刚活跃」。 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 + RevokedReason string `gorm:"size:30" json:"revoked_reason,omitempty"` // kicked|logout|admin|disabled|reuse|pwd_reset + RevokedBy *uint64 `gorm:"column:revoked_by" json:"revoked_by,omitempty"` // 吊销操作人 user_id;系统/自助吊销为 NULL RefreshExpAt time.Time `json:"refresh_exp_at"` } diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 78c5450..593deb3 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -16,7 +16,7 @@ func Setup(r *gin.Engine, db *gorm.DB) { stockSvc := service.NewStockService(db) // 处理器 - authH := handler.NewAuthHandler(authSvc) + authH := handler.NewAuthHandler(authSvc, licenseSvc) sessionH := handler.NewSessionHandler(authSvc) licenseH := handler.NewLicenseHandler(licenseSvc) productH := handler.NewProductHandler(db) @@ -95,7 +95,7 @@ func Setup(r *gin.Engine, db *gorm.DB) { // 业务路由:ReadOnly + LicenseGuard(过期只读/锁定拦截写操作) { - api.Use(middleware.ReadOnly(), middleware.LicenseGuard()) + api.Use(middleware.ReadOnly(), middleware.LicenseGuard(db)) // 商品 products := api.Group("/products") diff --git a/backend/internal/service/auth.go b/backend/internal/service/auth.go index 2f72043..83e87dc 100644 --- a/backend/internal/service/auth.go +++ b/backend/internal/service/auth.go @@ -24,6 +24,10 @@ var ( ErrPlatformNotAllowed = errors.New("该平台不允许登录") ErrTooManyAttempts = errors.New("登录失败次数过多,账号已临时锁定,请稍后再试") ErrSessionRevoked = errors.New("session revoked") + + // errRefreshReuse 内部哨兵:在续期事务内检测到 refresh token 重用, + // 用于让调用方在事务回滚后于事务外提交「吊销整条会话」。 + errRefreshReuse = errors.New("refresh token reuse detected") ) // DeviceInfo 登录请求携带的设备信息,用于会话记录与按平台限并发。 @@ -99,12 +103,14 @@ type TokenPair struct { func (s *AuthService) Login(shopCode, username, password string, dev DeviceInfo) (*TokenPair, *model.User, error) { limiterKey := shopCode + "|" + username if loginLim.locked(limiterKey) { + s.recordLoginAttempt(shopCode, username, dev, false, "locked") return nil, nil, ErrTooManyAttempts } var shop model.Shop if err := s.db.Where("code = ?", shopCode).First(&shop).Error; err != nil { loginLim.recordFailure(limiterKey) + s.recordLoginAttempt(shopCode, username, dev, false, "invalid_shop") return nil, nil, ErrInvalidCredentials } @@ -112,15 +118,18 @@ func (s *AuthService) Login(shopCode, username, password string, dev DeviceInfo) 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) + s.recordLoginAttempt(shopCode, username, dev, false, "invalid_user") return nil, nil, ErrInvalidCredentials } if !user.IsActive { + s.recordLoginAttempt(shopCode, username, dev, false, "inactive") return nil, nil, ErrUserInactive } if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { loginLim.recordFailure(limiterKey) + s.recordLoginAttempt(shopCode, username, dev, false, "bad_password") return nil, nil, ErrInvalidCredentials } @@ -137,10 +146,12 @@ func (s *AuthService) Login(shopCode, username, password string, dev DeviceInfo) pclass := model.PlatformClass(dev.Platform) quota := s.effectiveQuota(&shop, pclass) if quota <= 0 { + s.recordLoginAttempt(shopCode, username, dev, false, "platform_not_allowed") return nil, nil, ErrPlatformNotAllowed } 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 的活跃会话;超额踢最旧 @@ -169,6 +180,7 @@ func (s *AuthService) Login(shopCode, username, password string, dev DeviceInfo) PlatformClass: pclass, IP: dev.IP, UserAgent: dev.UserAgent, + RefreshJTI: jti, LastSeenAt: now, RefreshExpAt: now.Add(time.Duration(config.C.JWT.RefreshExpireH) * time.Hour), } @@ -183,13 +195,28 @@ func (s *AuthService) Login(shopCode, username, password string, dev DeviceInfo) loginLim.reset(limiterKey) user.LastLoginAt = &now - pair, err := s.issueTokens(user.ID, shop.ID, user.Role, sid) + pair, err := s.issueTokens(user.ID, shop.ID, user.Role, sid, jti) if err != nil { return nil, nil, err } return pair, &user, nil } +// recordLoginAttempt 记录一次登录尝试(当前只用于失败审计),写库失败仅记日志不阻断登录。 +func (s *AuthService) recordLoginAttempt(shopCode, username string, dev DeviceInfo, success bool, reason string) { + att := model.LoginAttempt{ + ShopCode: shopCode, + Username: username, + IP: dev.IP, + UserAgent: dev.UserAgent, + Success: success, + Reason: reason, + } + if err := s.db.Create(&att).Error; err != nil { + log.Printf("[auth] record login attempt failed: %v", err) + } +} + // effectiveQuota 返回某店某平台类的有效并发配额:优先每店 session_policy 覆盖,否则全局默认。 func (s *AuthService) effectiveQuota(shop *model.Shop, pclass string) int { def := map[string]int{ @@ -289,11 +316,11 @@ func (s *AuthService) ListSessions(shopID uint64, currentSID string) ([]SessionV return views, nil } -// ForceLogout 管理员强制下线本店某会话(按 id + shop_id 隔离)。 -func (s *AuthService) ForceLogout(shopID, sessionID uint64) error { +// ForceLogout 管理员强制下线本店某会话(按 id + shop_id 隔离)。byUserID 记入 revoked_by 供审计。 +func (s *AuthService) ForceLogout(shopID, sessionID, byUserID 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"}) + Updates(map[string]interface{}{"revoked_at": time.Now(), "revoked_reason": "admin", "revoked_by": byUserID}) if res.Error != nil { return res.Error } @@ -303,6 +330,18 @@ func (s *AuthService) ForceLogout(shopID, sessionID uint64) error { return nil } +// RevokeUserSessions 吊销某用户在本店的全部活跃会话(改密/禁用时调用)。byUserID 为操作人(记入 revoked_by)。 +// db 入参以便 handler 在不持有 AuthService 时也能复用同一语义。 +func RevokeUserSessions(db *gorm.DB, shopID, userID, byUserID uint64, reason string) error { + updates := map[string]interface{}{"revoked_at": time.Now(), "revoked_reason": reason} + if byUserID != 0 { + updates["revoked_by"] = byUserID + } + return db.Model(&model.UserSession{}). + Where("shop_id = ? AND user_id = ? AND revoked_at IS NULL", shopID, userID). + Updates(updates).Error +} + // RegisterInput 注册新门店所需参数 type RegisterInput struct { ShopName string `json:"shop_name" binding:"required"` @@ -392,14 +431,6 @@ func (s *AuthService) RefreshTokens(refreshToken string) (*TokenPair, error) { return nil, errors.New("invalid refresh token") } - // 带 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 { @@ -413,10 +444,109 @@ func (s *AuthService) RefreshTokens(refreshToken string) (*TokenPair, error) { return nil, err } - // 刷新会话存活时间(同 sid 续期)。 - if claims.SID != "" { - s.db.Model(&model.UserSession{}).Where("sid = ?", claims.SID). - Update("last_seen_at", time.Now()) + // 带 sid 的会话:在事务内 FOR UPDATE 锁行做「校验 → 重用检测 → 轮换」(check-then-act)。 + // 无 sid 的存量 token:首刷即建立可吊销会话并签发带 sid 的新 token,使其转入受控状态 + //(自此可被强制下线/踢人/改密吊销),不再永久游离于会话治理之外。 + newJTI := uuid.New().String() + now := time.Now() + sid := claims.SID + if sid == "" { + // 存量无 sid token:复用「该用户唯一的 legacy 会话」,而非每次续期都新建一条。 + // 事务 + FOR UPDATE 锁住既有 legacy 行、串行化并发的存量续期;找不到才创建。 + // 这样重复/并发呈递同一存量 token 始终只对应一条可吊销会话(有界、纳入会话治理、 + // 可被强制下线/改密吊销),不再每刷一条无限膨胀。正常客户端首刷后即拿到带 sid 的新 + // token,自然转入受控分支,此 legacy 行随其过期由清理任务回收。 + exp := now.Add(time.Duration(config.C.JWT.RefreshExpireH) * time.Hour) + if err := s.db.Transaction(func(tx *gorm.DB) error { + // 锁住该用户全部活跃 legacy 会话。legacy class 不在 effectiveQuota 的 + // desktop/mobile/web 映射内(会返回 0),若不在此显式约束,legacy 会话便游离于 + // 并发配额之外。这里把「每用户至多一条 legacy 会话」从 find-or-create 的隐式产物 + // 提升为显式上限:复用最早的一条(轮换 jti),其余多余 legacy 会话一并吊销。 + var sessions []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", + user.ShopID, user.ID, "legacy"). + Order("id ASC").Find(&sessions).Error; err != nil { + return err + } + if len(sessions) > 0 { + keep := sessions[0] + sid = keep.SID + if err := tx.Model(&model.UserSession{}).Where("id = ?", keep.ID). + Updates(map[string]interface{}{ + "refresh_jti": newJTI, + "last_seen_at": now, + "refresh_exp_at": exp, + }).Error; err != nil { + return err + } + if len(sessions) > 1 { + extraIDs := make([]uint64, 0, len(sessions)-1) + for _, ex := range sessions[1:] { + extraIDs = append(extraIDs, ex.ID) + } + if err := tx.Model(&model.UserSession{}).Where("id IN ?", extraIDs). + Updates(map[string]interface{}{"revoked_at": now, "revoked_reason": "kicked"}).Error; err != nil { + return err + } + } + return nil + } + newSID := uuid.New().String() + sess := model.UserSession{ + ShopID: user.ShopID, + UserID: user.ID, + SID: newSID, + Platform: "legacy", + PlatformClass: "legacy", + RefreshJTI: newJTI, + LastSeenAt: now, + RefreshExpAt: exp, + } + if err := tx.Create(&sess).Error; err != nil { + return err + } + sid = newSID + return nil + }); err != nil { + return nil, err + } + } else { + var reuseSessID uint64 + txErr := s.db.Transaction(func(tx *gorm.DB) error { + var sess model.UserSession + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("sid = ?", claims.SID).First(&sess).Error; err != nil { + return ErrSessionRevoked + } + if sess.RevokedAt != nil { + return ErrSessionRevoked + } + // 重用检测:会话已确立 jti,但呈递的 refresh token 携带的 jti 与当前值不符 → + // 说明这是已被轮换取代的旧 token 重放(盗用信号),吊销整条会话。 + // 向后兼容:sess.RefreshJTI=="" 为存量会话,首刷直接采纳新 jti,不报重用。 + // 注意:吊销动作放到事务外执行——此处一旦 return 非 nil 错误,事务会回滚, + // 在事务内写吊销会被一并回滚掉。 + if sess.RefreshJTI != "" && claims.ID != sess.RefreshJTI { + reuseSessID = sess.ID + return errRefreshReuse + } + // 轮换 jti + 刷新存活时间。 + return tx.Model(&model.UserSession{}).Where("id = ?", sess.ID). + Updates(map[string]interface{}{"refresh_jti": newJTI, "last_seen_at": now}).Error + }) + if errors.Is(txErr, errRefreshReuse) { + // 事务外提交吊销(不随回滚丢失),吊销整条会话。写库失败必须记日志—— + // 否则盗用信号被静默吞掉,被取代的旧 token 仍可继续续期,功能形同虚设。 + if err := s.db.Model(&model.UserSession{}).Where("id = ?", reuseSessID). + Updates(map[string]interface{}{"revoked_at": now, "revoked_reason": "reuse"}).Error; err != nil { + log.Printf("[auth] revoke reused session %d failed: %v", reuseSessID, err) + } + return nil, ErrSessionRevoked + } + if txErr != nil { + return nil, txErr + } } // 自动续登(refresh)路径同样补发首次试用:老用户用本地 refresh token 自动登录、 @@ -424,7 +554,7 @@ func (s *AuthService) RefreshTokens(refreshToken string) (*TokenPair, error) { // lic_exp 带上试用到期日。 s.ensureTrialOnFirstUse(user.ShopID) - return s.issueTokens(user.ID, user.ShopID, user.Role, claims.SID) + return s.issueTokens(user.ID, user.ShopID, user.Role, sid, newJTI) } // ensureTrialOnFirstUse 门店首次使用(尚无任何 is_active 授权)时自动签发 30 天 trial, @@ -432,17 +562,51 @@ func (s *AuthService) RefreshTokens(refreshToken string) (*TokenPair, error) { // 已有有效授权(含已过期但未锁定的 trial/付费)则跳过,不重复发放。 // 签发失败仅记日志、不阻断登录(保持可用,门店维持未激活)。 func (s *AuthService) ensureTrialOnFirstUse(shopID uint64) { + // 快路径:绝大多数登录/续期门店已有有效授权。先做一次无锁 Count 直接返回, + // 避免每次都开事务锁 shop 行——该函数在每次 Login/RefreshTokens 都被调用, + // 高频路径上的行锁会无谓串行化同店并发请求。 var count int64 if err := s.db.Model(&model.License{}). Where("shop_id = ? AND is_active = 1", shopID).Count(&count).Error; err != nil { - log.Printf("[license] count licenses for shop %d failed: %v", shopID, err) + log.Printf("[license] auto-trial precheck for shop %d failed: %v", shopID, err) return } if count > 0 { return } - if err := issueTrialLicense(s.db, shopID); err != nil { + + // 慢路径(首次使用):事务内锁住门店行后**重新** Count,串行化同店并发的「首次试用」 + // 判断:无既有 license 行可锁,故锁父级 shop 行,避免两个并发请求都读到 count==0 + // 各发一条 trial(重复授权)。 + issued := false + err := s.db.Transaction(func(tx *gorm.DB) error { + var shop model.Shop + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("id = ?", shopID).First(&shop).Error; err != nil { + return err + } + var c int64 + if err := tx.Model(&model.License{}). + Where("shop_id = ? AND is_active = 1", shopID).Count(&c).Error; err != nil { + return err + } + if c > 0 { + return nil + } + if err := issueTrialLicense(tx, shopID); err != nil { + return err + } + issued = true + return nil + }) + if err != nil { log.Printf("[license] auto-trial for shop %d skipped: %v", shopID, err) + return + } + // 事务提交后再失效 phase 缓存:此刻新签发的 trial 行对其它连接已可见, + // 不会在提交前的窗口里被旧 phase 重新填充(修复缓存失效早于提交的竞态)。 + if issued { + middleware.InvalidateLicensePhase(shopID) } } @@ -458,7 +622,7 @@ func (s *AuthService) checkLicenseNotLocked(shopID uint64) error { return nil } -func (s *AuthService) issueTokens(userID, shopID uint64, role, sid string) (*TokenPair, error) { +func (s *AuthService) issueTokens(userID, shopID uint64, role, sid, refreshJTI string) (*TokenPair, error) { cfg := config.C.JWT now := time.Now() @@ -496,6 +660,7 @@ func (s *AuthService) issueTokens(userID, shopID uint64, role, sid string) (*Tok SID: sid, LicenseExpiresAt: licExpAt, RegisteredClaims: jwt.RegisteredClaims{ + ID: refreshJTI, // jti:refresh token 轮换与重用检测的依据 ExpiresAt: jwt.NewNumericDate(refreshExp), IssuedAt: jwt.NewNumericDate(now), }, diff --git a/backend/internal/service/license.go b/backend/internal/service/license.go index 161a4bc..abb5b5a 100644 --- a/backend/internal/service/license.go +++ b/backend/internal/service/license.go @@ -13,6 +13,7 @@ import ( "gorm.io/gorm" "github.com/wangjia/jiu/backend/config" + "github.com/wangjia/jiu/backend/internal/middleware" "github.com/wangjia/jiu/backend/internal/model" "github.com/wangjia/jiu/backend/internal/util" ) @@ -62,6 +63,9 @@ func (s *LicenseService) Activate(shopID uint64, licenseKey, deviceID, deviceNam return nil, ErrLicenseExpired } + // 激活成功即清除该店 phase 缓存:续费/换新授权码后写权限即时恢复,不必等 30s TTL。 + defer middleware.InvalidateLicensePhase(shopID) + var existing model.LicenseDevice err := s.db.Where("license_id = ? AND device_id = ?", lic.ID, deviceID).First(&existing).Error if err == nil { @@ -116,11 +120,52 @@ func (s *LicenseService) ShopInfo(shopID uint64) (*model.License, error) { var lic model.License if err := s.db.Where("shop_id = ? AND is_active = 1", shopID). Order("id DESC").First(&lic).Error; err != nil { - return nil, ErrLicenseNotFound + // 仅「确无记录」才算无授权;DB 不可达等瞬时错误必须上抛, + // 否则会被误判为「门店无授权」,把客户端横幅/门禁错误降级。 + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrLicenseNotFound + } + return nil, err } return &lic, nil } +// LicenseInfoView 门店授权概况(含设备数与实时 phase),供 /license/info 与心跳 /auth/ping 复用, +// 保证两条路径返回结构一致。 +type LicenseInfoView struct { + ID uint64 `json:"id"` + Type string `json:"type"` + IsActive bool `json:"is_active"` + MaxDevices int `json:"max_devices"` + DeviceCount int64 `json:"device_count"` + ExpiresAt *time.Time `json:"expires_at"` + Phase string `json:"phase"` +} + +// ShopInfoView 返回门店授权概况;无有效授权时返回 (nil, nil),仅在统计设备数等查询出错时返回 error。 +func (s *LicenseService) ShopInfoView(shopID uint64) (*LicenseInfoView, error) { + lic, err := s.ShopInfo(shopID) + if err != nil { + if errors.Is(err, ErrLicenseNotFound) { + return nil, nil // 确无有效授权 + } + return nil, err // 瞬时错误上抛:Ping 据此省略 license 字段,客户端保留上次状态 + } + count, err := s.CountDevices(lic.ID) + if err != nil { + return nil, err + } + return &LicenseInfoView{ + ID: lic.ID, + Type: lic.Type, + IsActive: lic.IsActive, + MaxDevices: lic.MaxDevices, + DeviceCount: count, + ExpiresAt: lic.ExpiresAt, + Phase: middleware.CalcLicensePhase(lic.ExpiresAt), + }, nil +} + // CountDevices 返回指定 license 下已绑定设备数。 func (s *LicenseService) CountDevices(licenseID uint64) (int64, error) { var count int64 @@ -174,7 +219,13 @@ func issueTrialLicense(db *gorm.DB, shopID uint64) error { IsActive: true, MaxDevices: 1, } - return db.Create(&lic).Error + if err := db.Create(&lic).Error; err != nil { + return err + } + // 注意:phase 缓存失效不在此处做——本函数运行在调用方事务内,提交前失效会留下 + // 30s 窗口:并发请求可能在新 license 行可见前用旧 phase 重新填充缓存。 + // 失效改由调用方在事务提交后执行(见 ensureTrialOnFirstUse)。 + return nil } // createTrialLicense 在注册事务中为新门店签发 30 天 trial license。 diff --git a/backend/internal/service/session_cleanup.go b/backend/internal/service/session_cleanup.go new file mode 100644 index 0000000..68a0b3e --- /dev/null +++ b/backend/internal/service/session_cleanup.go @@ -0,0 +1,55 @@ +package service + +import ( + "log" + "time" + + "gorm.io/gorm" + + "github.com/wangjia/jiu/backend/internal/model" +) + +// sessionCleanupInterval 清理任务执行周期。 +const sessionCleanupInterval = 24 * time.Hour + +// StartSessionCleanup 启动后台清理 goroutine:启动即跑一次,之后每 24h 跑一次。 +// 删除已撤销/已过期的会话行与过旧的失败登录记录,防止表无限膨胀、IP/UA 长期滞留。 +// retentionDays<=0 时视为关闭清理(直接返回,不启动 goroutine)。 +func StartSessionCleanup(db *gorm.DB, retentionDays int) { + if retentionDays <= 0 { + log.Printf("[cleanup] session cleanup disabled (retention_days=%d)", retentionDays) + return + } + go func() { + cleanupOnce(db, retentionDays) + ticker := time.NewTicker(sessionCleanupInterval) + defer ticker.Stop() + for range ticker.C { + cleanupOnce(db, retentionDays) + } + }() +} + +// cleanupOnce 执行一轮清理,返回各表删除行数(供测试断言)。 +func cleanupOnce(db *gorm.DB, retentionDays int) (sessions, attempts int64) { + cutoff := time.Now().AddDate(0, 0, -retentionDays) + + r1 := db.Where( + "(revoked_at IS NOT NULL AND revoked_at < ?) OR (refresh_exp_at IS NOT NULL AND refresh_exp_at < ?)", + cutoff, cutoff, + ).Delete(&model.UserSession{}) + if r1.Error != nil { + log.Printf("[cleanup] purge user_sessions failed: %v", r1.Error) + } + + r2 := db.Where("created_at < ?", cutoff).Delete(&model.LoginAttempt{}) + if r2.Error != nil { + log.Printf("[cleanup] purge login_attempts failed: %v", r2.Error) + } + + if r1.RowsAffected > 0 || r2.RowsAffected > 0 { + log.Printf("[cleanup] purged %d sessions, %d login_attempts (older than %dd)", + r1.RowsAffected, r2.RowsAffected, retentionDays) + } + return r1.RowsAffected, r2.RowsAffected +} diff --git a/backend/internal/service/session_hardening_test.go b/backend/internal/service/session_hardening_test.go new file mode 100644 index 0000000..7b29348 --- /dev/null +++ b/backend/internal/service/session_hardening_test.go @@ -0,0 +1,292 @@ +package service + +import ( + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/wangjia/jiu/backend/config" + "github.com/wangjia/jiu/backend/internal/middleware" + "github.com/wangjia/jiu/backend/internal/model" + "github.com/wangjia/jiu/backend/testutil" +) + +// parseRefreshClaims 解析 refresh token 的 claims。 +func parseRefreshClaims(t *testing.T, token string) *middleware.Claims { + t.Helper() + claims := &middleware.Claims{} + _, err := jwt.ParseWithClaims(token, claims, func(*jwt.Token) (interface{}, error) { + return []byte(config.C.JWT.Secret), nil + }) + require.NoError(t, err) + return claims +} + +// parseRefreshJTI 取出 refresh token 的 jti(RegisteredClaims.ID)。 +func parseRefreshJTI(t *testing.T, token string) string { + return parseRefreshClaims(t, token).ID +} + +// signLegacyRefresh 签一个不带 jti 的 refresh token(模拟发版前的存量 token)。 +func signLegacyRefresh(t *testing.T, userID, shopID uint64, role, sid string) string { + t.Helper() + now := time.Now() + claims := middleware.Claims{ + UserID: userID, ShopID: shopID, Role: role, SID: sid, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)), + IssuedAt: jwt.NewNumericDate(now), + }, + } + s, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(config.C.JWT.Secret)) + require.NoError(t, err) + return s +} + +// #1 续期轮换 jti;旧 refresh token 重放 → 判定盗用 → 吊销整条会话。 +func TestRefreshTokens_RotationAndReuseDetection(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "HARD01") + user := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin") + svc := NewAuthService(db) + + pair1, _, err := svc.Login("HARD01", "admin", "password123", DeviceInfo{Platform: "windows"}) + require.NoError(t, err) + jti0 := parseRefreshJTI(t, pair1.RefreshToken) + require.NotEmpty(t, jti0) + + // 首次续期成功,jti 轮换。 + pair2, err := svc.RefreshTokens(pair1.RefreshToken) + require.NoError(t, err) + jti1 := parseRefreshJTI(t, pair2.RefreshToken) + assert.NotEqual(t, jti0, jti1, "续期应轮换 jti") + + // 重放已被取代的旧 refresh token → 盗用信号 → ErrSessionRevoked。 + _, err = svc.RefreshTokens(pair1.RefreshToken) + assert.ErrorIs(t, err, ErrSessionRevoked) + + // 整条会话被吊销,reason=reuse。 + var sess model.UserSession + require.NoError(t, db.Where("user_id = ?", user.ID).First(&sess).Error) + assert.NotNil(t, sess.RevokedAt) + assert.Equal(t, "reuse", sess.RevokedReason) + + // 即便是「最新」的 refresh token,此后也无法再续期(family 已撤销)。 + _, err = svc.RefreshTokens(pair2.RefreshToken) + assert.ErrorIs(t, err, ErrSessionRevoked) +} + +// #1 向后兼容:存量会话(refresh_jti 为空)+ 不带 jti 的旧 refresh token,首刷应放行并采纳新 jti。 +func TestRefreshTokens_LegacyTokenBackwardCompat(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "HARD02") + user := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin") + svc := NewAuthService(db) + + sid := uuid.New().String() + now := time.Now() + require.NoError(t, db.Create(&model.UserSession{ + ShopID: shop.ID, UserID: user.ID, SID: sid, + Platform: "windows", PlatformClass: "desktop", + RefreshJTI: "", // 存量会话无 jti + LastSeenAt: now, + RefreshExpAt: now.Add(time.Hour), + }).Error) + + legacy := signLegacyRefresh(t, user.ID, shop.ID, "admin", sid) + pair, err := svc.RefreshTokens(legacy) + require.NoError(t, err, "存量 token 首刷应放行") + + // 采纳新 jti 写回会话。 + newJTI := parseRefreshJTI(t, pair.RefreshToken) + assert.NotEmpty(t, newJTI) + var sess model.UserSession + require.NoError(t, db.Where("sid = ?", sid).First(&sess).Error) + assert.Equal(t, newJTI, sess.RefreshJTI) + assert.Nil(t, sess.RevokedAt) +} + +// #4 无 sid 的存量 token 首刷应自建可吊销会话,从此纳入会话治理(可被强制下线)。 +func TestRefreshTokens_LegacyNoSidAdoptsSession(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "HARD08") + user := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin") + svc := NewAuthService(db) + + // SID 为空的存量 refresh token(发版前签发,从无会话行)。 + legacy := signLegacyRefresh(t, user.ID, shop.ID, "admin", "") + pair, err := svc.RefreshTokens(legacy) + require.NoError(t, err, "无 sid 存量 token 首刷应放行") + + // 新 token 带上自建的 sid,且已落库一条会话。 + newSID := parseRefreshClaims(t, pair.RefreshToken).SID + require.NotEmpty(t, newSID, "首刷应签发带 sid 的新 token") + var sess model.UserSession + require.NoError(t, db.Where("user_id = ? AND sid = ?", user.ID, newSID).First(&sess).Error) + assert.Equal(t, "legacy", sess.PlatformClass) + assert.Nil(t, sess.RevokedAt) + + // 自此可被治理:管理员强制下线后,新 token 无法再续期。 + views, _ := svc.ListSessions(shop.ID, "") + require.Len(t, views, 1) + require.NoError(t, svc.ForceLogout(shop.ID, views[0].ID, 0)) + _, err = svc.RefreshTokens(pair.RefreshToken) + assert.ErrorIs(t, err, ErrSessionRevoked) +} + +// #1 同一无 sid 存量 token 重复续期,应复用唯一 legacy 会话而非每次新建(防无界膨胀 + 配额规避)。 +func TestRefreshTokens_LegacyNoSidReusesSingleSession(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "HARD09") + user := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin") + svc := NewAuthService(db) + + legacy := signLegacyRefresh(t, user.ID, shop.ID, "admin", "") + + // 反复呈递「同一」无 sid 存量 token(模拟未采纳新 token 的客户端/重放)。 + var firstSID string + for i := 0; i < 5; i++ { + pair, err := svc.RefreshTokens(legacy) + require.NoError(t, err, "存量 token 续期应放行") + sid := parseRefreshClaims(t, pair.RefreshToken).SID + require.NotEmpty(t, sid) + if i == 0 { + firstSID = sid + } else { + assert.Equal(t, firstSID, sid, "重复续期应复用同一 legacy 会话的 sid") + } + } + + // 始终只有一条 legacy 会话,而非 5 条。 + var count int64 + require.NoError(t, db.Model(&model.UserSession{}). + Where("shop_id = ? AND user_id = ? AND platform_class = ?", shop.ID, user.ID, "legacy"). + Count(&count).Error) + assert.EqualValues(t, 1, count, "重复存量续期不应无界新建会话") +} + +// #4 预存多条活跃 legacy 会话(legacy class 不在并发配额内)时,一次无 sid 续期应把它们 +// 收敛为一条:复用最早的一条、吊销其余,使「每用户至多一条 legacy 会话」成为显式强制的上限。 +func TestRefreshTokens_LegacyNoSidCollapsesExtraSessions(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "HARD10") + user := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin") + svc := NewAuthService(db) + + now := time.Now() + sids := []string{uuid.New().String(), uuid.New().String(), uuid.New().String()} + for _, sid := range sids { + require.NoError(t, db.Create(&model.UserSession{ + ShopID: shop.ID, UserID: user.ID, SID: sid, + Platform: "legacy", PlatformClass: "legacy", + RefreshJTI: "", LastSeenAt: now, RefreshExpAt: now.Add(time.Hour), + }).Error) + } + + legacy := signLegacyRefresh(t, user.ID, shop.ID, "admin", "") + pair, err := svc.RefreshTokens(legacy) + require.NoError(t, err) + + // 复用最早创建(id 最小)的那条会话。 + keptSID := parseRefreshClaims(t, pair.RefreshToken).SID + assert.Equal(t, sids[0], keptSID, "应复用最早的一条 legacy 会话") + + // 仅剩一条活跃 legacy 会话,其余被吊销(reason=kicked)。 + var active int64 + require.NoError(t, db.Model(&model.UserSession{}). + Where("shop_id = ? AND user_id = ? AND platform_class = ? AND revoked_at IS NULL", + shop.ID, user.ID, "legacy").Count(&active).Error) + assert.EqualValues(t, 1, active, "多余 legacy 会话应被收敛为一条") + + var revoked int64 + require.NoError(t, db.Model(&model.UserSession{}). + Where("shop_id = ? AND user_id = ? AND revoked_reason = ?", + shop.ID, user.ID, "kicked").Count(&revoked).Error) + assert.EqualValues(t, 2, revoked, "其余两条应以 kicked 吊销") +} + +// #4 ForceLogout 写入 revoked_by。 +func TestForceLogout_RecordsRevokedBy(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "HARD04") + testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin") + svc := NewAuthService(db) + + _, _, err := svc.Login("HARD04", "admin", "password123", DeviceInfo{Platform: "windows"}) + require.NoError(t, err) + views, _ := svc.ListSessions(shop.ID, "") + require.Len(t, views, 1) + + const adminID = uint64(42) + require.NoError(t, svc.ForceLogout(shop.ID, views[0].ID, adminID)) + + var sess model.UserSession + require.NoError(t, db.Where("id = ?", views[0].ID).First(&sess).Error) + require.NotNil(t, sess.RevokedBy) + assert.Equal(t, adminID, *sess.RevokedBy) + assert.Equal(t, "admin", sess.RevokedReason) +} + +// #5 清理:删除已撤销/过期会话与过旧失败登录,保留新鲜行。 +func TestCleanupOnce_PurgesStaleRows(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "HARD05") + user := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin") + + old := time.Now().AddDate(0, 0, -100) // 早于 90 天保留期 + fresh := time.Now() + + revokedOld := old + // 1) 久前撤销的会话 → 删 + require.NoError(t, db.Create(&model.UserSession{ + ShopID: shop.ID, UserID: user.ID, SID: "s-revoked-old", + LastSeenAt: old, RefreshExpAt: fresh.Add(time.Hour), RevokedAt: &revokedOld, + }).Error) + // 2) refresh 久前过期的会话 → 删 + require.NoError(t, db.Create(&model.UserSession{ + ShopID: shop.ID, UserID: user.ID, SID: "s-expired-old", + LastSeenAt: old, RefreshExpAt: old, + }).Error) + // 3) 新鲜活跃会话 → 保留 + require.NoError(t, db.Create(&model.UserSession{ + ShopID: shop.ID, UserID: user.ID, SID: "s-fresh", + LastSeenAt: fresh, RefreshExpAt: fresh.Add(time.Hour), + }).Error) + // 失败登录:旧 → 删;新 → 留 + require.NoError(t, db.Create(&model.LoginAttempt{Username: "x", Reason: "bad_password", CreatedAt: old}).Error) + require.NoError(t, db.Create(&model.LoginAttempt{Username: "y", Reason: "bad_password", CreatedAt: fresh}).Error) + + sessions, attempts := cleanupOnce(db, 90) + assert.Equal(t, int64(2), sessions) + assert.Equal(t, int64(1), attempts) + + var sessLeft, attLeft int64 + db.Model(&model.UserSession{}).Count(&sessLeft) + db.Model(&model.LoginAttempt{}).Count(&attLeft) + assert.Equal(t, int64(1), sessLeft) + assert.Equal(t, int64(1), attLeft) +} + +// #7 失败登录落库(ip/ua 来自 DeviceInfo,reason 正确)。 +func TestLogin_RecordsFailedAttempt(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "HARD07") + testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin") + svc := NewAuthService(db) + + dev := DeviceInfo{Platform: "windows", IP: "1.2.3.4", UserAgent: "curl/8.1"} + _, _, err := svc.Login("HARD07", "admin", "wrong-password", dev) + require.Error(t, err) + + var att model.LoginAttempt + require.NoError(t, db.Where("username = ?", "admin").First(&att).Error) + assert.False(t, att.Success) + assert.Equal(t, "bad_password", att.Reason) + assert.Equal(t, "1.2.3.4", att.IP) + assert.Equal(t, "curl/8.1", att.UserAgent) + assert.Equal(t, "HARD07", att.ShopCode) +} diff --git a/backend/internal/service/session_test.go b/backend/internal/service/session_test.go index 4b4deee..fb7ffa8 100644 --- a/backend/internal/service/session_test.go +++ b/backend/internal/service/session_test.go @@ -123,7 +123,7 @@ func TestForceLogout_RevokesSession(t *testing.T) { require.Len(t, views, 1) assert.True(t, views[0].Online) - require.NoError(t, svc.ForceLogout(shop.ID, views[0].ID)) + require.NoError(t, svc.ForceLogout(shop.ID, views[0].ID, 0)) _, err = svc.RefreshTokens(pair.RefreshToken) assert.ErrorIs(t, err, ErrSessionRevoked) @@ -148,7 +148,7 @@ func TestForceLogout_TenantIsolation(t *testing.T) { require.Len(t, views, 1) // 用 shopB 的 shopID 尝试下线 shopA 的会话 → 找不到 - err = svc.ForceLogout(shopB.ID, views[0].ID) + err = svc.ForceLogout(shopB.ID, views[0].ID, 0) assert.Error(t, err) } diff --git a/backend/main.go b/backend/main.go index 7de0ad1..99ab650 100644 --- a/backend/main.go +++ b/backend/main.go @@ -12,6 +12,7 @@ import ( "github.com/wangjia/jiu/backend/config" "github.com/wangjia/jiu/backend/internal/model" "github.com/wangjia/jiu/backend/internal/router" + "github.com/wangjia/jiu/backend/internal/service" ) func main() { @@ -37,6 +38,9 @@ func main() { // 自动迁移(GORM AutoMigrate 只增不删,生产安全) autoMigrate(db) + // 启动会话/失败登录保留期清理任务(后台 goroutine) + service.StartSessionCleanup(db, config.C.Session.RetentionDays) + // 启动 Gin gin.SetMode(config.C.Server.Mode) r := gin.New() @@ -98,6 +102,7 @@ func autoMigrate(db *gorm.DB) { &model.License{}, &model.LicenseDevice{}, &model.UserSession{}, + &model.LoginAttempt{}, &model.ProductCategory{}, &model.Product{}, &model.Warehouse{}, diff --git a/backend/schema/schema.sql b/backend/schema/schema.sql index 0b6067d..b68a5e3 100644 --- a/backend/schema/schema.sql +++ b/backend/schema/schema.sql @@ -67,10 +67,12 @@ CREATE TABLE IF NOT EXISTS `user_sessions` ( `platform_class` VARCHAR(20) DEFAULT NULL COMMENT 'desktop|mobile|web', `ip` VARCHAR(64) DEFAULT NULL, `user_agent` VARCHAR(512) DEFAULT NULL, + `refresh_jti` VARCHAR(64) DEFAULT NULL COMMENT '当前有效 refresh token 的 jti,用于轮换+重用检测', `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', + `revoked_reason` VARCHAR(30) DEFAULT NULL COMMENT 'kicked|logout|admin|disabled|reuse|pwd_reset', + `revoked_by` BIGINT UNSIGNED DEFAULT NULL COMMENT '吊销操作人 user_id;系统/自助吊销为 NULL', `refresh_exp_at` DATETIME DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_session_sid` (`sid`), @@ -79,6 +81,23 @@ CREATE TABLE IF NOT EXISTS `user_sessions` ( KEY `idx_session_revoked` (`revoked_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='登录会话'; +-- ------------------------------------------------------------ +-- 登录尝试审计(当前只记失败尝试,用于风控/排查;由保留期清理任务定期删旧) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `login_attempts` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `shop_code` VARCHAR(64) DEFAULT NULL, + `username` VARCHAR(50) DEFAULT NULL, + `ip` VARCHAR(64) DEFAULT NULL, + `user_agent` VARCHAR(512) DEFAULT NULL, + `success` TINYINT(1) NOT NULL DEFAULT 0, + `reason` VARCHAR(40) DEFAULT NULL COMMENT 'invalid_shop|invalid_user|bad_password|locked|inactive|platform_not_allowed', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_attempt_user` (`username`, `created_at`), + KEY `idx_attempt_ip` (`ip`, `created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='登录尝试审计'; + -- ------------------------------------------------------------ -- 许可证 -- ------------------------------------------------------------ diff --git a/backend/testutil/setup.go b/backend/testutil/setup.go index f0370c3..5350912 100644 --- a/backend/testutil/setup.go +++ b/backend/testutil/setup.go @@ -98,12 +98,24 @@ func SetupTestDB() *gorm.DB { platform_class TEXT, ip TEXT, user_agent TEXT, + refresh_jti TEXT, created_at DATETIME, last_seen_at DATETIME, revoked_at DATETIME, revoked_reason TEXT, + revoked_by INTEGER, refresh_exp_at DATETIME )`, + `CREATE TABLE IF NOT EXISTS login_attempts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + shop_code TEXT, + username TEXT, + ip TEXT, + user_agent TEXT, + success INTEGER DEFAULT 0, + reason TEXT, + created_at DATETIME + )`, `CREATE TABLE IF NOT EXISTS licenses ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME,