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
+100 -1
View File
@@ -1,6 +1,8 @@
package service
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"log"
@@ -217,10 +219,12 @@ func (s *AuthService) Login(shopCode, username, password string, dev DeviceInfo)
// 统计该 user 全部平台的活跃会话(不分平台类);已达上限则拒绝。
// 仅计未过期会话:refresh 已过期的行等待 cleanup 物理删除,不再占配额
// (历史 bug:过期未清理的会话把坑占满,正常用户被锁在门外)。
// kind=sso(App→网页免登录的短时会话)不占设备配额。
var activeCount int64
if err := tx.Model(&model.UserSession{}).
Set("gorm:query_option", "FOR UPDATE").
Where("shop_id = ? AND user_id = ? AND revoked_at IS NULL AND refresh_exp_at > ?",
Where("shop_id = ? AND user_id = ? AND revoked_at IS NULL AND refresh_exp_at > ?"+
" AND kind <> 'sso'",
shop.ID, user.ID, now).
Count(&activeCount).Error; err != nil {
return err
@@ -789,3 +793,98 @@ func (s *AuthService) issueTokens(userID, shopID uint64, role, sid, refreshJTI s
ShopID: shopID,
}, nil
}
// ═══════════════ App → 网页免登录(一次性换票,magic-link)═══════════════
// App 持 JWT 调 IssueWebTicket 签票(TTL 60s、单次可用),浏览器打开 /sso
// 页后凭票 LoginWithWebTicket 兑换正常 web 会话(kind=sso:不占设备配额、
// refresh 12 小时硬到期——RefreshTokens 轮换不延长 refresh_exp_at)。
const (
webTicketTTL = 60 * time.Second
ssoRefreshExpire = 12 * time.Hour
)
// ErrTicketInvalid 票据不存在 / 已使用 / 已过期。
var ErrTicketInvalid = errors.New("票据无效或已过期")
// IssueWebTicket 为已登录用户签发一次性网页登录票据。
func (s *AuthService) IssueWebTicket(shopID, userID uint64) (string, int, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", 0, err
}
ticket := base64.RawURLEncoding.EncodeToString(buf)
wt := model.WebTicket{
Ticket: ticket,
ShopID: shopID,
UserID: userID,
ExpiresAt: time.Now().Add(webTicketTTL),
}
if err := s.db.Create(&wt).Error; err != nil {
return "", 0, err
}
return ticket, int(webTicketTTL.Seconds()), nil
}
// LoginWithWebTicket 凭一次性票据兑换 web 会话。票据消费为原子置位
// UPDATE … WHERE used_at IS NULL),并发重放必失败。
func (s *AuthService) LoginWithWebTicket(
ticket string, dev DeviceInfo,
) (*TokenPair, *model.User, *model.Shop, error) {
now := time.Now()
res := s.db.Model(&model.WebTicket{}).
Where("ticket = ? AND used_at IS NULL AND expires_at > ?", ticket, now).
Update("used_at", now)
if res.Error != nil {
return nil, nil, nil, res.Error
}
if res.RowsAffected != 1 {
return nil, nil, nil, ErrTicketInvalid
}
var wt model.WebTicket
if err := s.db.Where("ticket = ?", ticket).First(&wt).Error; err != nil {
return nil, nil, nil, ErrTicketInvalid
}
var user model.User
if err := s.db.Where("id = ? AND shop_id = ?", wt.UserID, wt.ShopID).
First(&user).Error; err != nil {
return nil, nil, nil, ErrInvalidCredentials
}
if !user.IsActive {
return nil, nil, nil, ErrUserInactive
}
var shop model.Shop
if err := s.db.First(&shop, wt.ShopID).Error; err != nil {
return nil, nil, nil, err
}
// sso 会话:web 平台、短时效、不占配额(平台闸门不拦——票据由已登录
// 会话签出,权限等同签票用户本人)。
sid := uuid.New().String()
jti := uuid.New().String()
sess := model.UserSession{
ShopID: shop.ID,
UserID: user.ID,
SID: sid,
DeviceID: dev.DeviceID,
DeviceName: "网页免登(App 跳转)",
Platform: "web",
PlatformClass: "web",
Kind: "sso",
IP: dev.IP,
UserAgent: dev.UserAgent,
RefreshJTI: jti,
LastSeenAt: now,
RefreshExpAt: now.Add(ssoRefreshExpire),
}
if err := s.db.Create(&sess).Error; err != nil {
return nil, nil, nil, err
}
pair, err := s.issueTokens(user.ID, shop.ID, user.Role, sid, jti)
if err != nil {
return nil, nil, nil, err
}
return pair, &user, &shop, nil
}