会话/设备管理后端:user_sessions 会话表、JWT 加 sid 校验、按平台类限并发登录、 登录失败锁定、修复禁用账号仍可凭 refresh 续期漏洞、/auth/ping、/auth/logout、 GET /sessions、DELETE /sessions/:id(管理员强制下线)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,17 +3,22 @@ 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"`
|
||||
LicenseExpiresAt *int64 `json:"lic_exp,omitempty"` // unix seconds; nil = perpetual
|
||||
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
|
||||
}
|
||||
|
||||
@@ -21,10 +26,14 @@ const (
|
||||
CtxUserID = "user_id"
|
||||
CtxShopID = "shop_id"
|
||||
CtxRole = "role"
|
||||
CtxSID = "sid"
|
||||
CtxLicenseExpiresAt = "lic_exp"
|
||||
)
|
||||
|
||||
func JWT() gin.HandlerFunc {
|
||||
// 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 ") {
|
||||
@@ -42,14 +51,44 @@ func JWT() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// 会话校验:带 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 {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session revoked", "code": "SESSION_REVOKED"})
|
||||
return
|
||||
}
|
||||
// 节流刷新 last_seen,用于在线状态判定
|
||||
if time.Since(sess.LastSeenAt) > lastSeenThrottle {
|
||||
db.Model(&model.UserSession{}).Where("id = ?", sess.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) {
|
||||
|
||||
Reference in New Issue
Block a user