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:
@@ -3,6 +3,8 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/wangjia/jiu/backend/internal/middleware"
|
"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 语义一致。
|
// view 可能为 nil(确无授权)→ license:null,与 /license/info 的 data:null 语义一致。
|
||||||
util.RespondSuccess(c, gin.H{"ok": true, "license": view})
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ type UserSession struct {
|
|||||||
// 否则 revoke/cleanup 等任意 Updates 都会把已撤销会话误刷成「刚活跃」。
|
// 否则 revoke/cleanup 等任意 Updates 都会把已撤销会话误刷成「刚活跃」。
|
||||||
LastSeenAt time.Time `gorm:"autoCreateTime" json:"last_seen_at"`
|
LastSeenAt time.Time `gorm:"autoCreateTime" json:"last_seen_at"`
|
||||||
RevokedAt *time.Time `gorm:"index" json:"revoked_at,omitempty"`
|
RevokedAt *time.Time `gorm:"index" json:"revoked_at,omitempty"`
|
||||||
RevokedReason string `gorm:"size:30" json:"revoked_reason,omitempty"` // kicked|logout|admin|disabled|reuse|pwd_reset|relogin
|
RevokedReason string `gorm:"size:30" json:"revoked_reason,omitempty"` // kicked|logout|admin|disabled|reuse|pwd_reset|relogin
|
||||||
|
// Kind 会话种类:login=正常登录;sso=App 换票的网页短时会话
|
||||||
|
//(不计设备并发配额,refresh 短时效)。
|
||||||
|
Kind string `gorm:"size:10;default:'login'" json:"kind"`
|
||||||
RevokedBy *uint64 `gorm:"column:revoked_by" json:"revoked_by,omitempty"` // 吊销操作人 user_id;系统/自助吊销为 NULL
|
RevokedBy *uint64 `gorm:"column:revoked_by" json:"revoked_by,omitempty"` // 吊销操作人 user_id;系统/自助吊销为 NULL
|
||||||
RefreshExpAt time.Time `json:"refresh_exp_at"`
|
RefreshExpAt time.Time `json:"refresh_exp_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// WebTicket App→网页免登录的一次性换票凭据(magic-link)。
|
||||||
|
// App 持 JWT 签票(TTL 60s),浏览器打开 /sso 后凭票兑换正常 web 会话;
|
||||||
|
// 单次可用(used_at 原子置位防重放),票据本身不含任何令牌信息。
|
||||||
|
type WebTicket struct {
|
||||||
|
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
Ticket string `gorm:"size:64;not null;uniqueIndex" json:"-"`
|
||||||
|
ShopID uint64 `gorm:"not null" json:"shop_id"`
|
||||||
|
UserID uint64 `gorm:"not null" json:"user_id"`
|
||||||
|
ExpiresAt time.Time `gorm:"not null;index" json:"expires_at"`
|
||||||
|
UsedAt *time.Time `json:"used_at,omitempty"`
|
||||||
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (WebTicket) TableName() string { return "web_tickets" }
|
||||||
@@ -70,6 +70,8 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
|||||||
{
|
{
|
||||||
auth.POST("/login", loginIP, authH.Login)
|
auth.POST("/login", loginIP, authH.Login)
|
||||||
auth.POST("/refresh", refreshIP, authH.Refresh)
|
auth.POST("/refresh", refreshIP, authH.Refresh)
|
||||||
|
// App→网页免登录:一次性票据兑换(公开;票据 60s 单次可用,复用 refresh 限流)
|
||||||
|
auth.POST("/web-ticket/exchange", refreshIP, authH.WebTicketExchange)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 公开接口(无需登录):读接口防爬、写接口防刷
|
// 公开接口(无需登录):读接口防爬、写接口防刷
|
||||||
@@ -95,6 +97,8 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
|||||||
{
|
{
|
||||||
api.POST("/auth/logout", authH.Logout)
|
api.POST("/auth/logout", authH.Logout)
|
||||||
api.POST("/auth/ping", authH.Ping)
|
api.POST("/auth/ping", authH.Ping)
|
||||||
|
// App→网页免登录:签发一次性票据(需登录)
|
||||||
|
api.POST("/auth/web-ticket", authH.WebTicket)
|
||||||
// 在线会话列表:所有登录用户只读
|
// 在线会话列表:所有登录用户只读
|
||||||
api.GET("/sessions", sessionH.List)
|
api.GET("/sessions", sessionH.List)
|
||||||
// 强制下线:仅 admin/superadmin
|
// 强制下线:仅 admin/superadmin
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@@ -217,10 +219,12 @@ func (s *AuthService) Login(shopCode, username, password string, dev DeviceInfo)
|
|||||||
// 统计该 user 全部平台的活跃会话(不分平台类);已达上限则拒绝。
|
// 统计该 user 全部平台的活跃会话(不分平台类);已达上限则拒绝。
|
||||||
// 仅计未过期会话:refresh 已过期的行等待 cleanup 物理删除,不再占配额
|
// 仅计未过期会话:refresh 已过期的行等待 cleanup 物理删除,不再占配额
|
||||||
// (历史 bug:过期未清理的会话把坑占满,正常用户被锁在门外)。
|
// (历史 bug:过期未清理的会话把坑占满,正常用户被锁在门外)。
|
||||||
|
// kind=sso(App→网页免登录的短时会话)不占设备配额。
|
||||||
var activeCount int64
|
var activeCount int64
|
||||||
if err := tx.Model(&model.UserSession{}).
|
if err := tx.Model(&model.UserSession{}).
|
||||||
Set("gorm:query_option", "FOR UPDATE").
|
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).
|
shop.ID, user.ID, now).
|
||||||
Count(&activeCount).Error; err != nil {
|
Count(&activeCount).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -789,3 +793,98 @@ func (s *AuthService) issueTokens(userID, shopID uint64, role, sid, refreshJTI s
|
|||||||
ShopID: shopID,
|
ShopID: shopID,
|
||||||
}, nil
|
}, 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)
|
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 {
|
if r1.RowsAffected > 0 || r2.RowsAffected > 0 {
|
||||||
log.Printf("[cleanup] purged %d sessions, %d login_attempts (older than %dd)",
|
log.Printf("[cleanup] purged %d sessions, %d login_attempts (older than %dd)",
|
||||||
r1.RowsAffected, r2.RowsAffected, retentionDays)
|
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 会话不占配额,正常登录不受影响")
|
||||||
|
}
|
||||||
@@ -148,6 +148,7 @@ func autoMigrate(db *gorm.DB) {
|
|||||||
&model.ProductImage{},
|
&model.ProductImage{},
|
||||||
&model.ErrorReport{},
|
&model.ErrorReport{},
|
||||||
&model.Feedback{},
|
&model.Feedback{},
|
||||||
|
&model.WebTicket{},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("auto migrate failed: %v", err)
|
log.Fatalf("auto migrate failed: %v", err)
|
||||||
|
|||||||
@@ -71,9 +71,10 @@ CREATE TABLE IF NOT EXISTS `user_sessions` (
|
|||||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
`last_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
`last_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
`revoked_at` DATETIME DEFAULT NULL,
|
`revoked_at` DATETIME DEFAULT NULL,
|
||||||
`revoked_reason` VARCHAR(30) DEFAULT NULL COMMENT 'kicked|logout|admin|disabled|reuse|pwd_reset',
|
`revoked_reason` VARCHAR(30) DEFAULT NULL COMMENT 'kicked|logout|admin|disabled|reuse|pwd_reset|relogin',
|
||||||
`revoked_by` BIGINT UNSIGNED DEFAULT NULL COMMENT '吊销操作人 user_id;系统/自助吊销为 NULL',
|
`revoked_by` BIGINT UNSIGNED DEFAULT NULL COMMENT '吊销操作人 user_id;系统/自助吊销为 NULL',
|
||||||
`refresh_exp_at` DATETIME DEFAULT NULL,
|
`refresh_exp_at` DATETIME DEFAULT NULL,
|
||||||
|
`kind` VARCHAR(10) DEFAULT 'login' COMMENT 'login=正常登录;sso=App换票的网页短时会话(不计设备配额)',
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
UNIQUE KEY `uk_session_sid` (`sid`),
|
UNIQUE KEY `uk_session_sid` (`sid`),
|
||||||
KEY `idx_session_shop_user` (`shop_id`, `user_id`),
|
KEY `idx_session_shop_user` (`shop_id`, `user_id`),
|
||||||
@@ -596,4 +597,20 @@ CREATE TABLE IF NOT EXISTS `feedbacks` (
|
|||||||
KEY `idx_status` (`status`)
|
KEY `idx_status` (`status`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户意见反馈';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户意见反馈';
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- App→网页免登录一次性换票(magic-link;TTL 60s 单次可用,used_at 原子置位防重放)
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `web_tickets` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`ticket` VARCHAR(64) NOT NULL,
|
||||||
|
`shop_id` BIGINT UNSIGNED NOT NULL,
|
||||||
|
`user_id` BIGINT UNSIGNED NOT NULL,
|
||||||
|
`expires_at` DATETIME NOT NULL,
|
||||||
|
`used_at` DATETIME DEFAULT NULL,
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_web_ticket` (`ticket`),
|
||||||
|
KEY `idx_web_ticket_expires` (`expires_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='App→网页免登录一次性票据';
|
||||||
|
|
||||||
SET FOREIGN_KEY_CHECKS = 1;
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
|
|||||||
@@ -106,7 +106,17 @@ func SetupTestDB() *gorm.DB {
|
|||||||
revoked_at DATETIME,
|
revoked_at DATETIME,
|
||||||
revoked_reason TEXT,
|
revoked_reason TEXT,
|
||||||
revoked_by INTEGER,
|
revoked_by INTEGER,
|
||||||
refresh_exp_at DATETIME
|
refresh_exp_at DATETIME,
|
||||||
|
kind TEXT DEFAULT 'login'
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS web_tickets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ticket TEXT NOT NULL UNIQUE,
|
||||||
|
shop_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
used_at DATETIME,
|
||||||
|
created_at DATETIME
|
||||||
)`,
|
)`,
|
||||||
`CREATE TABLE IF NOT EXISTS login_attempts (
|
`CREATE TABLE IF NOT EXISTS login_attempts (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// core/utils/web_launch.dart — App→官网免登录跳转(一次性换票 SSO)。
|
||||||
|
// 先向后端签一张 60s 单次票据,再打开 官网/sso?t=<票>&redirect=<路径>,
|
||||||
|
// 落地页兑票写入登录态后跳目标页——桌面/移动同一套(launchUrl 跨端)。
|
||||||
|
// 签票失败(未登录/网络异常)时降级为直接打开目标链接(未登录态浏览)。
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
import '../api/api_client.dart';
|
||||||
|
import '../config/app_config.dart';
|
||||||
|
|
||||||
|
/// 打开官网 [path](如 `/#pricing`、`/profile/`)并自动登录。
|
||||||
|
Future<void> launchAuthedWebPath(WidgetRef ref, String path) async {
|
||||||
|
final base = AppConfig.publicBaseUrl;
|
||||||
|
Uri target = Uri.parse('$base$path');
|
||||||
|
try {
|
||||||
|
final resp = await ref.read(apiClientProvider).post('/auth/web-ticket');
|
||||||
|
final ticket = resp.data?['data']?['ticket'] as String?;
|
||||||
|
if (ticket != null && ticket.isNotEmpty) {
|
||||||
|
target = Uri.parse(
|
||||||
|
'$base/sso/?t=$ticket&redirect=${Uri.encodeComponent(path)}');
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 签票失败降级:未登录态直开目标页
|
||||||
|
}
|
||||||
|
await launchUrl(target, mode: LaunchMode.externalApplication);
|
||||||
|
}
|
||||||
@@ -4,11 +4,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
|
||||||
|
|
||||||
import '../../core/auth/auth_state.dart';
|
import '../../core/auth/auth_state.dart';
|
||||||
import '../../core/config/app_info.dart';
|
|
||||||
import '../../core/config/license_copy.dart';
|
import '../../core/config/license_copy.dart';
|
||||||
|
import '../../core/utils/web_launch.dart';
|
||||||
import '../../core/responsive/responsive.dart';
|
import '../../core/responsive/responsive.dart';
|
||||||
import '../../core/theme/app_dims.g.dart';
|
import '../../core/theme/app_dims.g.dart';
|
||||||
import '../../core/theme/app_fonts.dart';
|
import '../../core/theme/app_fonts.dart';
|
||||||
@@ -114,8 +113,8 @@ class _LicensePanelState extends ConsumerState<LicensePanel> {
|
|||||||
Text('在线购买请联系门店管理员 · ',
|
Text('在线购买请联系门店管理员 · ',
|
||||||
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
|
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: () =>
|
// 免登跳官网(一次性换票 SSO):浏览器落地即已登录,可直接购买
|
||||||
launchUrl(Uri.parse('${AppInfo.website}/#pricing')),
|
onTap: () => launchAuthedWebPath(ref, '/#pricing'),
|
||||||
child: Text('查看套餐价格 →',
|
child: Text('查看套餐价格 →',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: AppDims.fsSm,
|
fontSize: AppDims.fsSm,
|
||||||
|
|||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
---
|
||||||
|
layout: base.njk
|
||||||
|
title: 正在登录
|
||||||
|
description: App 免登录跳转中。
|
||||||
|
permalink: /sso/
|
||||||
|
---
|
||||||
|
{# App→网页免登录落地页:App 先 POST /api/v1/auth/web-ticket 签一次性票据
|
||||||
|
(TTL 60s、单次可用),再打开 /sso/?t=<ticket>&redirect=<本站路径>。
|
||||||
|
本页凭票 POST /auth/web-ticket/exchange 兑换正常登录态,写入与 Web 版
|
||||||
|
App 共享的 localStorage['flutter.*'] 后跳 redirect。
|
||||||
|
安全:URL 只含票据不含令牌;redirect 仅接受本站相对路径(防 open redirect);
|
||||||
|
兑换失败一律落到 /login/ 并回传 redirect。 #}
|
||||||
|
<div class="auth-wrap">
|
||||||
|
<div class="auth" style="grid-template-columns:1fr;max-width:420px">
|
||||||
|
<div class="auth-form" style="text-align:center">
|
||||||
|
<h1 id="ssoTitle">正在登录…</h1>
|
||||||
|
<p class="alead" id="ssoMsg">正在验证来自 App 的登录票据</p>
|
||||||
|
<div class="auth-alert" id="ssoErr"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/assets/auth.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var API = '';
|
||||||
|
function $(id) { return document.getElementById(id); }
|
||||||
|
|
||||||
|
// redirect 白名单:仅本站相对路径(以单个 / 开头),其余一律回 /profile/
|
||||||
|
function safeRedirect() {
|
||||||
|
var p = new URLSearchParams(location.search).get('redirect') || '/profile/';
|
||||||
|
if (p.charAt(0) !== '/' || p.charAt(1) === '/' || p.indexOf('\\') >= 0) {
|
||||||
|
return '/profile/';
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 与 login.njk persist 同格式(shared_preferences_web,值 JSON 编码)
|
||||||
|
function persist(data) {
|
||||||
|
var u = data.user || {};
|
||||||
|
var kv = {
|
||||||
|
access_token: data.access_token, refresh_token: data.refresh_token,
|
||||||
|
user_id: u.id || 0, username: u.username || '', real_name: u.real_name || '',
|
||||||
|
shop_no: data.shop_code || '', shop_id: String(data.shop_id || ''),
|
||||||
|
role: u.role || 'operator',
|
||||||
|
};
|
||||||
|
Object.keys(kv).forEach(function (k) {
|
||||||
|
localStorage.setItem('flutter.' + k, JSON.stringify(kv[k]));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(msg) {
|
||||||
|
$('ssoTitle').textContent = '登录跳转失败';
|
||||||
|
$('ssoMsg').textContent = '';
|
||||||
|
var el = $('ssoErr');
|
||||||
|
el.textContent = msg + ',请手动登录';
|
||||||
|
el.classList.add('show');
|
||||||
|
setTimeout(function () {
|
||||||
|
location.replace('/login/?redirect=' + encodeURIComponent(safeRedirect()));
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
var ticket = new URLSearchParams(location.search).get('t');
|
||||||
|
// 票据用后即从地址栏抹掉(不留浏览器历史)
|
||||||
|
try { history.replaceState(null, '', '/sso/'); } catch (e) {}
|
||||||
|
if (!ticket) { fail('缺少登录票据'); return; }
|
||||||
|
|
||||||
|
fetch(API + '/api/v1/auth/web-ticket/exchange', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ticket: ticket }),
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
|
||||||
|
.then(function (res) {
|
||||||
|
var d = res.j && res.j.data;
|
||||||
|
if (!res.ok || !d || !d.access_token) {
|
||||||
|
return fail((res.j && res.j.error) || '票据无效或已过期');
|
||||||
|
}
|
||||||
|
persist(d);
|
||||||
|
location.replace(safeRedirect());
|
||||||
|
})
|
||||||
|
.catch(function () { fail('网络异常'); });
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user