53fa259284
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>
141 lines
3.8 KiB
Go
141 lines
3.8 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 != "" {
|
||
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) {
|
||
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" {
|
||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "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()
|
||
}
|
||
}
|