会话/设备管理后端: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:
@@ -1,9 +1,11 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/wangjia/jiu/backend/internal/middleware"
|
||||
"github.com/wangjia/jiu/backend/internal/service"
|
||||
"github.com/wangjia/jiu/backend/internal/util"
|
||||
)
|
||||
@@ -19,18 +21,34 @@ func NewAuthHandler(svc *service.AuthService) *AuthHandler {
|
||||
// Login POST /api/v1/auth/login
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
var req struct {
|
||||
ShopCode string `json:"shop_code" binding:"required"`
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
ShopCode string `json:"shop_code" binding:"required"`
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
Platform string `json:"platform"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
pair, user, err := h.svc.Login(req.ShopCode, req.Username, req.Password)
|
||||
dev := service.DeviceInfo{
|
||||
DeviceID: req.DeviceID,
|
||||
DeviceName: req.DeviceName,
|
||||
Platform: req.Platform,
|
||||
IP: c.ClientIP(),
|
||||
UserAgent: c.Request.UserAgent(),
|
||||
}
|
||||
pair, user, err := h.svc.Login(req.ShopCode, req.Username, req.Password, dev)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
status := http.StatusUnauthorized
|
||||
if errors.Is(err, service.ErrTooManyAttempts) {
|
||||
status = http.StatusTooManyRequests
|
||||
} else if errors.Is(err, service.ErrPlatformNotAllowed) {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -79,9 +97,28 @@ func (h *AuthHandler) Refresh(c *gin.Context) {
|
||||
|
||||
pair, err := h.svc.RefreshTokens(req.RefreshToken)
|
||||
if err != nil {
|
||||
// 被踢/登出/禁用 → 给前端明确 code,便于提示"账号已在其他设备登录/已失效"
|
||||
if errors.Is(err, service.ErrSessionRevoked) || errors.Is(err, service.ErrUserInactive) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error(), "code": "SESSION_REVOKED"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
util.RespondSuccess(c, pair)
|
||||
}
|
||||
|
||||
// Logout POST /api/v1/auth/logout —— 撤销当前会话
|
||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
if err := h.svc.Logout(middleware.GetSID(c)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
util.RespondSuccess(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// Ping POST /api/v1/auth/ping —— 心跳,仅触发中间件刷新 last_seen;会话已撤销则中间件直接 401
|
||||
func (h *AuthHandler) Ping(c *gin.Context) {
|
||||
util.RespondSuccess(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestFeedbackHandler_SubmitAndList(t *testing.T) {
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
api := r.Group("/api/v1")
|
||||
api.Use(middleware.JWT())
|
||||
api.Use(middleware.JWT(db))
|
||||
api.POST("/feedback", fh.Submit)
|
||||
adminG := api.Group("/admin")
|
||||
adminG.Use(middleware.SuperAdminOnly())
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/wangjia/jiu/backend/internal/middleware"
|
||||
"github.com/wangjia/jiu/backend/internal/service"
|
||||
"github.com/wangjia/jiu/backend/internal/util"
|
||||
)
|
||||
|
||||
type SessionHandler struct {
|
||||
svc *service.AuthService
|
||||
}
|
||||
|
||||
func NewSessionHandler(svc *service.AuthService) *SessionHandler {
|
||||
return &SessionHandler{svc: svc}
|
||||
}
|
||||
|
||||
// List GET /api/v1/sessions —— 列出本店在线会话(所有登录用户只读)
|
||||
func (h *SessionHandler) List(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
views, err := h.svc.ListSessions(shopID, middleware.GetSID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
util.RespondSuccess(c, views)
|
||||
}
|
||||
|
||||
// ForceLogout DELETE /api/v1/sessions/:id —— 强制下线(仅 admin/superadmin,挂 AdminOnly)
|
||||
func (h *SessionHandler) ForceLogout(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session id"})
|
||||
return
|
||||
}
|
||||
shopID := middleware.GetShopID(c)
|
||||
if err := h.svc.ForceLogout(shopID, id); err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
util.RespondSuccess(c, gin.H{"ok": true})
|
||||
}
|
||||
@@ -32,7 +32,7 @@ func setupProtectedRouter(db *gorm.DB) *gin.Engine {
|
||||
r.Use(gin.Recovery())
|
||||
|
||||
api := r.Group("/api/v1")
|
||||
api.Use(middleware.JWT())
|
||||
api.Use(middleware.JWT(db))
|
||||
|
||||
// 商品路由
|
||||
products := api.Group("/products")
|
||||
|
||||
Reference in New Issue
Block a user