Files
jiu/backend/internal/middleware/auth.go
T
wangjia 53fa259284
Deploy Server / release-deploy-server (push) Successful in 40s
chore: release server-v1.0.56
会话/设备管理后端:user_sessions 会话表、JWT 加 sid 校验、按平台类限并发登录、
登录失败锁定、修复禁用账号仍可凭 refresh 续期漏洞、/auth/ping、/auth/logout、
GET /sessions、DELETE /sessions/:id(管理员强制下线)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 23:12:13 +08:00

141 lines
3.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}
}