Files
jiu/backend/internal/middleware/license_guard.go
T
wangjia e41085a878 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>
2026-06-19 07:34:04 +08:00

147 lines
5.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package middleware
import (
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/internal/model"
)
const (
PhaseNormal = "normal"
PhaseGrace = "grace" // expired 07 days: writable, show banner
PhaseReadOnly = "readonly" // expired 715 days: read-only
PhaseLocked = "locked" // expired 15+ days: no login
)
var (
graceWindow = 7 * 24 * time.Hour
readOnlyWindow = 15 * 24 * time.Hour
)
// CalcLicensePhase computes the degradation phase based on expires_at.
// nil expiresAt = perpetual license = normal.
func CalcLicensePhase(expiresAt *time.Time) string {
if expiresAt == nil {
return PhaseNormal
}
elapsed := time.Since(*expiresAt)
if elapsed <= 0 {
return PhaseNormal
}
if elapsed <= graceWindow {
return PhaseGrace
}
if elapsed <= readOnlyWindow {
return PhaseReadOnly
}
return PhaseLocked
}
// 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)
if ptr == nil {
return PhaseNormal
}
t := time.Unix(*ptr, 0)
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(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
phase := liveLicensePhase(db, c)
switch phase {
case PhaseLocked:
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "授权已锁定,请续费或激活新授权码",
"phase": PhaseLocked,
})
case PhaseReadOnly:
if c.Request.Method != http.MethodGet {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "授权已过期,当前为只读模式",
"phase": PhaseReadOnly,
})
return
}
c.Next()
default:
c.Next()
}
}
}