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:
@@ -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()
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user