chore: release server-v1.0.56
Deploy Server / release-deploy-server (push) Successful in 40s

会话/设备管理后端: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:
wangjia
2026-06-17 23:12:13 +08:00
parent 7c82553594
commit 53fa259284
16 changed files with 720 additions and 32 deletions
+44 -5
View File
@@ -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) {