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>
125 lines
3.4 KiB
Go
125 lines
3.4 KiB
Go
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"
|
|
)
|
|
|
|
type AuthHandler struct {
|
|
svc *service.AuthService
|
|
}
|
|
|
|
func NewAuthHandler(svc *service.AuthService) *AuthHandler {
|
|
return &AuthHandler{svc: svc}
|
|
}
|
|
|
|
// 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"`
|
|
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
|
|
}
|
|
|
|
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 {
|
|
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
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"data": gin.H{
|
|
"access_token": pair.AccessToken,
|
|
"refresh_token": pair.RefreshToken,
|
|
"expires_in": pair.ExpiresIn,
|
|
"shop_id": pair.ShopID,
|
|
"user": gin.H{
|
|
"id": user.ID,
|
|
"username": user.Username,
|
|
"real_name": user.RealName,
|
|
"role": user.Role,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
// Register POST /api/v1/public/register
|
|
func (h *AuthHandler) Register(c *gin.Context) {
|
|
var req service.RegisterInput
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
result, err := h.svc.Register(req)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
util.RespondSuccess(c, result)
|
|
}
|
|
|
|
// Refresh POST /api/v1/auth/refresh
|
|
func (h *AuthHandler) Refresh(c *gin.Context) {
|
|
var req struct {
|
|
RefreshToken string `json:"refresh_token" binding:"required"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
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})
|
|
}
|