e41085a878
会话安全(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>
147 lines
5.1 KiB
Go
147 lines
5.1 KiB
Go
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 0–7 days: writable, show banner
|
||
PhaseReadOnly = "readonly" // expired 7–15 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()
|
||
}
|
||
}
|
||
}
|