feat(backend): 会话安全加固 + 授权实时 phase + 首次使用自动试用

会话安全(jti 轮换 / 重用检测 / 改密吊销 / 禁用即时下线 / 清理 / 失败登录落库):
- refresh token 轮换 jti + token-family 重用检测,旧 token 重放即吊销整条会话
- 改密码、停用用户即时吊销其全部活跃会话(revoked_by 审计)
- 中间件 session JOIN user 校验,禁用/删除用户带 token 请求返回 401 USER_DISABLED
- 新增 login_attempts 失败登录落库 + 会话保留期清理 goroutine

授权实时 phase + 心跳回带:
- LicenseGuard 改为按当前 DB 实时计算 phase(30s 每店缓存),续费/过期/被改 ~30s 内对写操作生效,无需重登
- /auth/ping 回带授权概况(ShopInfoView,与 /license/info 同构),客户端一次心跳即刷新横幅/门禁

首次使用自动试用 + code-review 修复:
- 门店首次登录/续期无有效授权时自动签发 30 天 trial(快路径无锁 Count,仅首用走 FOR UPDATE 事务)
- ShopInfo 区分「确无授权」与瞬时 DB 错误,避免误降级
- trial 签发后改为在事务提交后再失效 phase 缓存(修复早于提交的竞态)
- 存量无 sid token 续期纳入显式上限,legacy 会话不再游离于并发配额之外

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-19 07:34:04 +08:00
parent 2d84bda99a
commit e41085a878
23 changed files with 1248 additions and 74 deletions
+19 -5
View File
@@ -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})
}
+53 -11
View File
@@ -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"])
}
+3 -19
View File
@@ -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 — 已绑定设备列表
+1 -1
View File
@@ -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
@@ -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)
}
+12
View File
@@ -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": "密码已重置"})
}
@@ -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 应被拦截")
}
})
}
}