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>
158 lines
4.5 KiB
Go
158 lines
4.5 KiB
Go
package middleware
|
||
|
||
import (
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/golang-jwt/jwt/v5"
|
||
"gorm.io/gorm"
|
||
|
||
"github.com/wangjia/jiu/backend/config"
|
||
"github.com/wangjia/jiu/backend/internal/model"
|
||
)
|
||
|
||
type Claims struct {
|
||
UserID uint64 `json:"user_id"`
|
||
ShopID uint64 `json:"shop_id"`
|
||
Role string `json:"role"`
|
||
SID string `json:"sid,omitempty"` // 服务端会话标识(user_sessions.sid)
|
||
LicenseExpiresAt *int64 `json:"lic_exp,omitempty"` // unix seconds; nil = perpetual
|
||
jwt.RegisteredClaims
|
||
}
|
||
|
||
const (
|
||
CtxUserID = "user_id"
|
||
CtxShopID = "shop_id"
|
||
CtxRole = "role"
|
||
CtxSID = "sid"
|
||
CtxLicenseExpiresAt = "lic_exp"
|
||
)
|
||
|
||
// lastSeenThrottle 控制 last_seen_at 写频率,避免每请求一写。
|
||
const lastSeenThrottle = 30 * time.Second
|
||
|
||
func JWT(db *gorm.DB) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
authHeader := c.GetHeader("Authorization")
|
||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
|
||
return
|
||
}
|
||
|
||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||
claims := &Claims{}
|
||
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
|
||
return []byte(config.C.JWT.Secret), nil
|
||
})
|
||
if err != nil || !token.Valid {
|
||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||
return
|
||
}
|
||
|
||
// 会话校验:带 sid 的 token 必须对应一条未撤销会话(支持踢人/登出/禁用即时失效)。
|
||
// 存量无 sid 的 token 过渡放行(其 access ≤60min 过期后会换到带 sid 的会话)。
|
||
if claims.SID != "" {
|
||
// 一次查询同时取会话 + 用户启用状态:兜底「直接改库 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(row.UserSession.LastSeenAt) > lastSeenThrottle {
|
||
db.Model(&model.UserSession{}).Where("id = ?", row.UserSession.ID).
|
||
Update("last_seen_at", time.Now())
|
||
}
|
||
}
|
||
|
||
c.Set(CtxUserID, claims.UserID)
|
||
c.Set(CtxShopID, claims.ShopID)
|
||
c.Set(CtxRole, claims.Role)
|
||
c.Set(CtxSID, claims.SID)
|
||
c.Set(CtxLicenseExpiresAt, claims.LicenseExpiresAt)
|
||
c.Next()
|
||
}
|
||
}
|
||
|
||
// GetSID 从 context 中获取当前会话 sid(可能为空:存量无 sid token)。
|
||
func GetSID(c *gin.Context) string {
|
||
v, _ := c.Get(CtxSID)
|
||
s, _ := v.(string)
|
||
return s
|
||
}
|
||
|
||
// GetRole 从 context 中获取当前用户角色。
|
||
func GetRole(c *gin.Context) string {
|
||
v, _ := c.Get(CtxRole)
|
||
s, _ := v.(string)
|
||
return s
|
||
}
|
||
|
||
// AdminOnly 仅管理员可访问
|
||
func AdminOnly() gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
role, _ := c.Get(CtxRole)
|
||
if role != "admin" && role != "superadmin" {
|
||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin only"})
|
||
return
|
||
}
|
||
c.Next()
|
||
}
|
||
}
|
||
|
||
// ReadOnly 只读用户禁止写操作
|
||
func ReadOnly() gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
role, _ := c.Get(CtxRole)
|
||
if role == "readonly" && c.Request.Method != "GET" {
|
||
// code 供前端区分「角色只读」与「授权过期」(后者由 LicenseGuard 返回 phase)
|
||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||
"error": "readonly user",
|
||
"code": "READONLY_USER",
|
||
})
|
||
return
|
||
}
|
||
c.Next()
|
||
}
|
||
}
|
||
|
||
// GetShopID 从 context 中安全获取 shop_id
|
||
func GetShopID(c *gin.Context) uint64 {
|
||
v, _ := c.Get(CtxShopID)
|
||
id, _ := v.(uint64)
|
||
return id
|
||
}
|
||
|
||
// GetUserID 从 context 中安全获取 user_id
|
||
func GetUserID(c *gin.Context) uint64 {
|
||
v, _ := c.Get(CtxUserID)
|
||
id, _ := v.(uint64)
|
||
return id
|
||
}
|
||
|
||
// SuperAdminOnly 仅超级管理员可访问
|
||
func SuperAdminOnly() gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
role, _ := c.Get(CtxRole)
|
||
if role != "superadmin" {
|
||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "superadmin only"})
|
||
return
|
||
}
|
||
c.Next()
|
||
}
|
||
}
|