diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index 2e38d2e..6ae1b5b 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -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:///sso?t=&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, + }, + }, + }) +} diff --git a/backend/internal/model/user_session.go b/backend/internal/model/user_session.go index f267ade..8e0f15d 100644 --- a/backend/internal/model/user_session.go +++ b/backend/internal/model/user_session.go @@ -24,7 +24,10 @@ type UserSession struct { // 否则 revoke/cleanup 等任意 Updates 都会把已撤销会话误刷成「刚活跃」。 LastSeenAt time.Time `gorm:"autoCreateTime" json:"last_seen_at"` 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 RefreshExpAt time.Time `json:"refresh_exp_at"` } diff --git a/backend/internal/model/web_ticket.go b/backend/internal/model/web_ticket.go new file mode 100644 index 0000000..21a7a3b --- /dev/null +++ b/backend/internal/model/web_ticket.go @@ -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" } diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 3371bab..56658d0 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -70,6 +70,8 @@ func Setup(r *gin.Engine, db *gorm.DB) { { auth.POST("/login", loginIP, authH.Login) 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/ping", authH.Ping) + // App→网页免登录:签发一次性票据(需登录) + api.POST("/auth/web-ticket", authH.WebTicket) // 在线会话列表:所有登录用户只读 api.GET("/sessions", sessionH.List) // 强制下线:仅 admin/superadmin diff --git a/backend/internal/service/auth.go b/backend/internal/service/auth.go index fe374d7..6ad63f7 100644 --- a/backend/internal/service/auth.go +++ b/backend/internal/service/auth.go @@ -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 +} diff --git a/backend/internal/service/session_cleanup.go b/backend/internal/service/session_cleanup.go index 68a0b3e..4dbd0b4 100644 --- a/backend/internal/service/session_cleanup.go +++ b/backend/internal/service/session_cleanup.go @@ -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) diff --git a/backend/internal/service/web_ticket_test.go b/backend/internal/service/web_ticket_test.go new file mode 100644 index 0000000..821dd93 --- /dev/null +++ b/backend/internal/service/web_ticket_test.go @@ -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 会话不占配额,正常登录不受影响") +} diff --git a/backend/main.go b/backend/main.go index 71b0310..ee1190a 100644 --- a/backend/main.go +++ b/backend/main.go @@ -148,6 +148,7 @@ func autoMigrate(db *gorm.DB) { &model.ProductImage{}, &model.ErrorReport{}, &model.Feedback{}, + &model.WebTicket{}, ) if err != nil { log.Fatalf("auto migrate failed: %v", err) diff --git a/backend/schema/schema.sql b/backend/schema/schema.sql index 584dba1..ef6cf60 100644 --- a/backend/schema/schema.sql +++ b/backend/schema/schema.sql @@ -71,9 +71,10 @@ CREATE TABLE IF NOT EXISTS `user_sessions` ( `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `last_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `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', `refresh_exp_at` DATETIME DEFAULT NULL, + `kind` VARCHAR(10) DEFAULT 'login' COMMENT 'login=正常登录;sso=App换票的网页短时会话(不计设备配额)', PRIMARY KEY (`id`), UNIQUE KEY `uk_session_sid` (`sid`), KEY `idx_session_shop_user` (`shop_id`, `user_id`), @@ -596,4 +597,20 @@ CREATE TABLE IF NOT EXISTS `feedbacks` ( KEY `idx_status` (`status`) ) 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; diff --git a/backend/testutil/setup.go b/backend/testutil/setup.go index 555bb8d..9704587 100644 --- a/backend/testutil/setup.go +++ b/backend/testutil/setup.go @@ -106,7 +106,17 @@ func SetupTestDB() *gorm.DB { revoked_at DATETIME, revoked_reason TEXT, 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 ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/client/lib/core/utils/web_launch.dart b/client/lib/core/utils/web_launch.dart new file mode 100644 index 0000000..442ef70 --- /dev/null +++ b/client/lib/core/utils/web_launch.dart @@ -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 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); +} diff --git a/client/lib/screens/settings/license_panel.dart b/client/lib/screens/settings/license_panel.dart index 53e58cf..42c873a 100644 --- a/client/lib/screens/settings/license_panel.dart +++ b/client/lib/screens/settings/license_panel.dart @@ -4,11 +4,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; -import 'package:url_launcher/url_launcher.dart'; import '../../core/auth/auth_state.dart'; -import '../../core/config/app_info.dart'; import '../../core/config/license_copy.dart'; +import '../../core/utils/web_launch.dart'; import '../../core/responsive/responsive.dart'; import '../../core/theme/app_dims.g.dart'; import '../../core/theme/app_fonts.dart'; @@ -114,8 +113,8 @@ class _LicensePanelState extends ConsumerState { Text('在线购买请联系门店管理员 · ', style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), InkWell( - onTap: () => - launchUrl(Uri.parse('${AppInfo.website}/#pricing')), + // 免登跳官网(一次性换票 SSO):浏览器落地即已登录,可直接购买 + onTap: () => launchAuthedWebPath(ref, '/#pricing'), child: Text('查看套餐价格 →', style: TextStyle( fontSize: AppDims.fsSm, diff --git a/web/sso.njk b/web/sso.njk new file mode 100644 index 0000000..72bfecb --- /dev/null +++ b/web/sso.njk @@ -0,0 +1,84 @@ +--- +layout: base.njk +title: 正在登录 +description: App 免登录跳转中。 +permalink: /sso/ +--- +{# App→网页免登录落地页:App 先 POST /api/v1/auth/web-ticket 签一次性票据 + (TTL 60s、单次可用),再打开 /sso/?t=&redirect=<本站路径>。 + 本页凭票 POST /auth/web-ticket/exchange 兑换正常登录态,写入与 Web 版 + App 共享的 localStorage['flutter.*'] 后跳 redirect。 + 安全:URL 只含票据不含令牌;redirect 仅接受本站相对路径(防 open redirect); + 兑换失败一律落到 /login/ 并回传 redirect。 #} +
+
+
+

正在登录…

+

正在验证来自 App 的登录票据

+
+
+
+
+ + +