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