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
+22 -5
View File
@@ -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()