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
@@ -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 应被拦截")
}
})
}
}