a773a33779
- backend:POST /auth/web-ticket(需登录,签 60s 单次票据,每用户限频)+ POST /auth/web-ticket/exchange(公开,原子即焚兑换正常登录态,响应同 /auth/login 另带 shop_code);web_tickets 新表 + user_sessions.kind 列 (sso 会话:web 平台、refresh 12h 硬到期、不占设备并发配额、设备管理可见 可踢);清理任务顺带清过期票据;schema.sql/AutoMigrate/testutil 同步 - web:/sso 落地页——兑票写入与 Web 版共享的 localStorage 后跳 redirect; redirect 白名单只收本站相对路径(防 open redirect),票据即时从地址栏抹除 - client:launchAuthedWebPath(ref, path) 封装(签票→拼 /sso URL→launchUrl, 失败降级未登录直开;桌面/移动同一套);授权面板「查看套餐价格」接入 test(backend): web_ticket_test 三用例(兑换+续期/单次即焚/过期拒绝/ sso 不占配额双向验证) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
216 lines
6.8 KiB
Go
216 lines
6.8 KiB
Go
package handler
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"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
|
|
licSvc *service.LicenseService // 用于心跳 /auth/ping 回带授权概况;测试构造可传 nil
|
|
}
|
|
|
|
func NewAuthHandler(svc *service.AuthService, licSvc *service.LicenseService) *AuthHandler {
|
|
return &AuthHandler{svc: svc, licSvc: licSvc}
|
|
}
|
|
|
|
// 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
|
|
} else if errors.Is(err, service.ErrDeviceLimitReached) {
|
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "DEVICE_LIMIT"})
|
|
return
|
|
}
|
|
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 —— 心跳:会话已撤销则中间件直接 401(触发登出),
|
|
// 否则刷新 last_seen 并**回带当前授权概况**,使客户端无需再单独轮询 /license/info
|
|
// (到期/续费/被改动等变化随心跳即时反映到横幅/只读门禁)。
|
|
func (h *AuthHandler) Ping(c *gin.Context) {
|
|
if h.licSvc == nil {
|
|
util.RespondSuccess(c, gin.H{"ok": true})
|
|
return
|
|
}
|
|
view, err := h.licSvc.ShopInfoView(middleware.GetShopID(c))
|
|
if err != nil {
|
|
// 授权查询出错不应让心跳失败(会话本身有效):省略 license 字段,客户端保留上次状态。
|
|
util.RespondSuccess(c, gin.H{"ok": true})
|
|
return
|
|
}
|
|
// view 可能为 nil(确无授权)→ license:null,与 /license/info 的 data:null 语义一致。
|
|
util.RespondSuccess(c, gin.H{"ok": true, "license": view})
|
|
}
|
|
|
|
// ═══════════════ App → 网页免登录(一次性换票)═══════════════
|
|
|
|
// webTicketLast 签票限频:每用户 1 秒至多 1 张(票据本就要求已登录,此为防刷兜底)。
|
|
var webTicketLast sync.Map // userID → time.Time
|
|
|
|
// WebTicket POST /api/v1/auth/web-ticket(需登录):签发一次性网页登录票据。
|
|
// App 拿到票后拼 https://<host>/sso?t=<ticket>&redirect=<路径> 打开系统浏览器。
|
|
func (h *AuthHandler) WebTicket(c *gin.Context) {
|
|
shopID := middleware.GetShopID(c)
|
|
userID := middleware.GetUserID(c)
|
|
if last, ok := webTicketLast.Load(userID); ok &&
|
|
time.Since(last.(time.Time)) < time.Second {
|
|
c.JSON(http.StatusTooManyRequests, gin.H{"error": "请求过于频繁,请稍后再试"})
|
|
return
|
|
}
|
|
webTicketLast.Store(userID, time.Now())
|
|
|
|
ticket, ttl, err := h.svc.IssueWebTicket(shopID, userID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "签发失败"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": gin.H{
|
|
"ticket": ticket,
|
|
"expires_in": ttl,
|
|
}})
|
|
}
|
|
|
|
// WebTicketExchange POST /api/v1/auth/web-ticket/exchange(公开):
|
|
// 网页端凭一次性票据兑换正常登录态(响应结构同 /auth/login,另带 shop_code
|
|
// 供官网 localStorage 回填)。票据单次可用,无效/过期/重放一律 401。
|
|
func (h *AuthHandler) WebTicketExchange(c *gin.Context) {
|
|
var req struct {
|
|
Ticket string `json:"ticket" binding:"required"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
dev := service.DeviceInfo{
|
|
Platform: "web",
|
|
IP: c.ClientIP(),
|
|
UserAgent: c.Request.UserAgent(),
|
|
}
|
|
pair, user, shop, err := h.svc.LoginWithWebTicket(req.Ticket, dev)
|
|
if err != nil {
|
|
if errors.Is(err, service.ErrTicketInvalid) ||
|
|
errors.Is(err, service.ErrInvalidCredentials) ||
|
|
errors.Is(err, service.ErrUserInactive) {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "票据无效或已过期"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"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,
|
|
"shop_code": shop.Code,
|
|
"user": gin.H{
|
|
"id": user.ID,
|
|
"username": user.Username,
|
|
"real_name": user.RealName,
|
|
"role": user.Role,
|
|
},
|
|
},
|
|
})
|
|
}
|