feat: App→网页免登录(一次性换票 SSO)——移动/桌面点链接直达已登录官网

- 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>
This commit is contained in:
wangjia
2026-07-04 21:30:08 +08:00
parent 11413df3db
commit a773a33779
13 changed files with 446 additions and 8 deletions
+74
View File
@@ -3,6 +3,8 @@ package handler
import (
"errors"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/wangjia/jiu/backend/internal/middleware"
@@ -139,3 +141,75 @@ func (h *AuthHandler) Ping(c *gin.Context) {
// 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,
},
},
})
}