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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -47,6 +47,12 @@ func cleanupOnce(db *gorm.DB, retentionDays int) (sessions, attempts int64) {
|
||||
log.Printf("[cleanup] purge login_attempts failed: %v", r2.Error)
|
||||
}
|
||||
|
||||
// 一次性网页登录票据:过期即无用(TTL 60s),过期一天后物理清除
|
||||
if err := db.Where("expires_at < ?", time.Now().AddDate(0, 0, -1)).
|
||||
Delete(&model.WebTicket{}).Error; err != nil {
|
||||
log.Printf("[cleanup] purge web_tickets failed: %v", err)
|
||||
}
|
||||
|
||||
if r1.RowsAffected > 0 || r2.RowsAffected > 0 {
|
||||
log.Printf("[cleanup] purged %d sessions, %d login_attempts (older than %dd)",
|
||||
r1.RowsAffected, r2.RowsAffected, retentionDays)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package service
|
||||
|
||||
// App→网页免登录一次性换票的回归测试:签票/兑票/单次即焚/过期/sso 不占配额。
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/wangjia/jiu/backend/config"
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
"github.com/wangjia/jiu/backend/testutil"
|
||||
)
|
||||
|
||||
func TestWebTicket_ExchangeOnce(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "SSO001")
|
||||
user := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
svc := NewAuthService(db)
|
||||
|
||||
ticket, ttl, err := svc.IssueWebTicket(shop.ID, user.ID)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, ticket)
|
||||
assert.Equal(t, 60, ttl)
|
||||
|
||||
dev := DeviceInfo{Platform: "web", IP: "1.2.3.4", UserAgent: "test"}
|
||||
pair, u, sh, err := svc.LoginWithWebTicket(ticket, dev)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, pair.AccessToken)
|
||||
assert.Equal(t, user.ID, u.ID)
|
||||
assert.Equal(t, "SSO001", sh.Code)
|
||||
|
||||
// 兑出的 refresh token 可正常续期(会话有效)
|
||||
_, err = svc.RefreshTokens(pair.RefreshToken)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 单次即焚:二次兑换必失败
|
||||
_, _, _, err = svc.LoginWithWebTicket(ticket, dev)
|
||||
assert.ErrorIs(t, err, ErrTicketInvalid)
|
||||
}
|
||||
|
||||
func TestWebTicket_Expired(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "SSO002")
|
||||
user := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
svc := NewAuthService(db)
|
||||
|
||||
ticket, _, err := svc.IssueWebTicket(shop.ID, user.ID)
|
||||
require.NoError(t, err)
|
||||
// 手动把票据改成已过期
|
||||
require.NoError(t, db.Model(&model.WebTicket{}).
|
||||
Where("ticket = ?", ticket).
|
||||
Update("expires_at", time.Now().Add(-time.Minute)).Error)
|
||||
|
||||
_, _, _, err = svc.LoginWithWebTicket(ticket, DeviceInfo{Platform: "web"})
|
||||
assert.ErrorIs(t, err, ErrTicketInvalid)
|
||||
}
|
||||
|
||||
func TestWebTicket_SsoSessionNotCountedInQuota(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
old := config.C.Session.LimitTotal
|
||||
config.C.Session.LimitTotal = 2
|
||||
defer func() { config.C.Session.LimitTotal = old }()
|
||||
|
||||
shop := testutil.CreateTestShop(db, "SSO003")
|
||||
user := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
svc := NewAuthService(db)
|
||||
|
||||
// 占满 2 个正常配额
|
||||
_, _, err := svc.Login("SSO003", "admin", "password123",
|
||||
DeviceInfo{DeviceID: "d1", Platform: "macos"})
|
||||
require.NoError(t, err)
|
||||
_, _, err = svc.Login("SSO003", "admin", "password123",
|
||||
DeviceInfo{DeviceID: "d2", Platform: "ios"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 配额已满仍可换票登录(sso 不受配额限制)
|
||||
ticket, _, err := svc.IssueWebTicket(shop.ID, user.ID)
|
||||
require.NoError(t, err)
|
||||
_, _, _, err = svc.LoginWithWebTicket(ticket, DeviceInfo{Platform: "web"})
|
||||
require.NoError(t, err, "sso 会话不受设备配额限制")
|
||||
|
||||
// sso 会话短时效 + 打标记
|
||||
var sess model.UserSession
|
||||
require.NoError(t, db.Where("shop_id = ? AND kind = 'sso'", shop.ID).
|
||||
First(&sess).Error)
|
||||
assert.Equal(t, "web", sess.Platform)
|
||||
assert.WithinDuration(t, time.Now().Add(12*time.Hour), sess.RefreshExpAt,
|
||||
time.Minute)
|
||||
|
||||
// 反向:sso 会话在场时,正常登录的配额统计不把它算进去
|
||||
_, _, err = svc.Login("SSO003", "admin", "password123",
|
||||
DeviceInfo{DeviceID: "d1", Platform: "macos"}) // 同设备重登复用坑位
|
||||
assert.NoError(t, err, "sso 会话不占配额,正常登录不受影响")
|
||||
}
|
||||
Reference in New Issue
Block a user