package service import ( "errors" "fmt" "log" "sync" "time" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" "github.com/wangjia/jiu/backend/config" "github.com/wangjia/jiu/backend/internal/middleware" "github.com/wangjia/jiu/backend/internal/model" ) var ( ErrInvalidCredentials = errors.New("invalid username or password") ErrUserInactive = errors.New("user is disabled") ErrLicenseLocked = errors.New("license locked, please renew or contact support") ErrPlatformNotAllowed = errors.New("该平台不允许登录") ErrDeviceLimitReached = errors.New("已达到登录设备上限,请提升额度或下线其他设备后重试") ErrTooManyAttempts = errors.New("登录失败次数过多,账号已临时锁定,请稍后再试") ErrSessionRevoked = errors.New("session revoked") // errRefreshReuse 内部哨兵:在续期事务内检测到 refresh token 重用, // 用于让调用方在事务回滚后于事务外提交「吊销整条会话」。 errRefreshReuse = errors.New("refresh token reuse detected") ) // DeviceInfo 登录请求携带的设备信息,用于会话记录与按平台限并发。 type DeviceInfo struct { DeviceID string DeviceName string Platform string IP string UserAgent string } // loginLimiter 内存登录失败限流器(单实例,重启即清零)。 // 两个维度共用同一张表:账号维度 key="|",IP 维度 key="ip|", // 分别用不同阈值锁定。带 janitor 清理空闲 entry,避免攻击者用随机 key 灌爆内存。 type loginLimiter struct { mu sync.Mutex entries map[string]*limiterEntry janitorOnce sync.Once } type limiterEntry struct { failures int lockedTill time.Time lastSeen time.Time } // loginLimiterIdleTTL:已解锁且超过该时长未活动的 entry 会被 janitor 清理。 const loginLimiterIdleTTL = 30 * time.Minute var loginLim = &loginLimiter{entries: map[string]*limiterEntry{}} // locked 返回该 key 是否处于锁定中。 func (l *loginLimiter) locked(key string) bool { l.mu.Lock() defer l.mu.Unlock() e := l.entries[key] if e == nil { return false } e.lastSeen = time.Now() return time.Now().Before(e.lockedTill) } // recordFailure 记一次失败,达到 max 阈值则锁定(max<=0 表示该维度不锁)。 func (l *loginLimiter) recordFailure(key string, max int) { l.startJanitor() l.mu.Lock() defer l.mu.Unlock() e := l.entries[key] if e == nil { e = &limiterEntry{} l.entries[key] = e } e.lastSeen = time.Now() e.failures++ if max > 0 && e.failures >= max { e.lockedTill = time.Now().Add(time.Duration(config.C.Session.LockMinutes) * time.Minute) e.failures = 0 } } // reset 登录成功后清除失败计数。 func (l *loginLimiter) reset(key string) { l.mu.Lock() defer l.mu.Unlock() delete(l.entries, key) } // startJanitor 惰性启动后台清理(仅一次):每 5 分钟淘汰「未锁定且超过 TTL 未活动」的 entry。 func (l *loginLimiter) startJanitor() { l.janitorOnce.Do(func() { go func() { t := time.NewTicker(5 * time.Minute) defer t.Stop() for range t.C { now := time.Now() l.mu.Lock() for k, e := range l.entries { if now.After(e.lockedTill) && now.Sub(e.lastSeen) > loginLimiterIdleTTL { delete(l.entries, k) } } l.mu.Unlock() } }() }) } type AuthService struct { db *gorm.DB } func NewAuthService(db *gorm.DB) *AuthService { return &AuthService{db: db} } type TokenPair struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresIn int `json:"expires_in"` // 秒 ShopID uint64 `json:"shop_id"` } // Login 账号密码登录 func (s *AuthService) Login(shopCode, username, password string, dev DeviceInfo) (*TokenPair, *model.User, error) { // 两个限流维度:账号维度防针对单账号的密码爆破;IP 维度(阈值更高)防单 IP 撞多个账号、 // 以及用随机账号灌爆内存。IP 为空(本地/测试)时退化为仅账号维度。 limiterKey := shopCode + "|" + username ipKey := "" if dev.IP != "" { ipKey = "ip|" + dev.IP } recordFail := func() { loginLim.recordFailure(limiterKey, config.C.Session.MaxFailures) if ipKey != "" { loginLim.recordFailure(ipKey, config.C.Session.IPMaxFailures) } } if loginLim.locked(limiterKey) || (ipKey != "" && loginLim.locked(ipKey)) { s.recordLoginAttempt(shopCode, username, dev, false, "locked") return nil, nil, ErrTooManyAttempts } var shop model.Shop if err := s.db.Where("code = ?", shopCode).First(&shop).Error; err != nil { recordFail() s.recordLoginAttempt(shopCode, username, dev, false, "invalid_shop") return nil, nil, ErrInvalidCredentials } var user model.User if err := s.db.Where("shop_id = ? AND username = ? AND deleted_at IS NULL", shop.ID, username). First(&user).Error; err != nil { recordFail() s.recordLoginAttempt(shopCode, username, dev, false, "invalid_user") return nil, nil, ErrInvalidCredentials } if !user.IsActive { s.recordLoginAttempt(shopCode, username, dev, false, "inactive") return nil, nil, ErrUserInactive } if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { recordFail() s.recordLoginAttempt(shopCode, username, dev, false, "bad_password") return nil, nil, ErrInvalidCredentials } if err := s.checkLicenseNotLocked(shop.ID); err != nil { return nil, nil, err } // 首次使用(门店尚无任何有效授权)自动发 30 天试用:seed/手动建店或老数据 // 登录即转为 trial,「未激活」不再静默无限制。须在 issueTokens 之前,使 JWT // 的 lic_exp 带上新试用到期日。 s.ensureTrialOnFirstUse(shop.ID) // 平台闸门:配额 0=禁止该平台登录(如禁 web);数值不再作分平台上限,并发统一按总数限。 pclass := model.PlatformClass(dev.Platform) if s.effectiveQuota(&shop, pclass) <= 0 { s.recordLoginAttempt(shopCode, username, dev, false, "platform_not_allowed") return nil, nil, ErrPlatformNotAllowed } // 总并发上限:同一用户跨全部平台合计不超过 totalQuota。已满时拒绝本次登录(不再 // 静默踢最旧),让用户自行下线其他设备或提升额度。 totalQuota := s.effectiveTotalQuota(&shop) sid := uuid.New().String() jti := uuid.New().String() // 初始 refresh token jti,后续每次续期轮换 now := time.Now() if err := s.db.Transaction(func(tx *gorm.DB) error { // 统计该 user 全部平台的活跃会话(不分平台类);已达上限则拒绝 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", shop.ID, user.ID). Count(&activeCount).Error; err != nil { return err } if activeCount >= int64(totalQuota) { return ErrDeviceLimitReached } sess := model.UserSession{ ShopID: shop.ID, UserID: user.ID, SID: sid, DeviceID: dev.DeviceID, DeviceName: dev.DeviceName, Platform: dev.Platform, PlatformClass: pclass, IP: dev.IP, UserAgent: dev.UserAgent, RefreshJTI: jti, LastSeenAt: now, RefreshExpAt: now.Add(time.Duration(config.C.JWT.RefreshExpireH) * time.Hour), } if err := tx.Create(&sess).Error; err != nil { return err } return tx.Model(&model.User{}).Where("id = ?", user.ID).Update("last_login_at", now).Error }); err != nil { if errors.Is(err, ErrDeviceLimitReached) { s.recordLoginAttempt(shopCode, username, dev, false, "device_limit") } return nil, nil, err } loginLim.reset(limiterKey) if ipKey != "" { loginLim.reset(ipKey) } user.LastLoginAt = &now pair, err := s.issueTokens(user.ID, shop.ID, user.Role, sid, jti) if err != nil { return nil, nil, err } return pair, &user, nil } // recordLoginAttempt 记录一次登录尝试(当前只用于失败审计),写库失败仅记日志不阻断登录。 func (s *AuthService) recordLoginAttempt(shopCode, username string, dev DeviceInfo, success bool, reason string) { att := model.LoginAttempt{ ShopCode: shopCode, Username: username, IP: dev.IP, UserAgent: dev.UserAgent, Success: success, Reason: reason, } if err := s.db.Create(&att).Error; err != nil { log.Printf("[auth] record login attempt failed: %v", err) } } // effectiveTotalQuota 返回某店「单用户跨全部平台」的有效并发总配额:优先每店 // session_policy["total"] 覆盖,否则全局默认 config.C.Session.LimitTotal。至少为 1, // 避免误配 0 时把所有会话踢光。 func (s *AuthService) effectiveTotalQuota(shop *model.Shop) int { q := config.C.Session.LimitTotal if shop.CustomFields != nil { if raw, ok := shop.CustomFields["session_policy"]; ok { if policy, ok := raw.(map[string]interface{}); ok { switch n := policy["total"].(type) { case float64: if int(n) > 0 { q = int(n) } case int: if n > 0 { q = n } } } } } if q < 1 { q = 1 } return q } // effectiveQuota 返回某店某平台类的平台闸门值:>0=允许该平台,0=禁止。优先每店 // session_policy 覆盖,否则全局默认。注意:数值大小不再作分平台并发上限(已统一为总数)。 func (s *AuthService) effectiveQuota(shop *model.Shop, pclass string) int { def := map[string]int{ "desktop": config.C.Session.LimitDesktop, "mobile": config.C.Session.LimitMobile, "web": config.C.Session.LimitWeb, }[pclass] if shop.CustomFields == nil { return def } raw, ok := shop.CustomFields["session_policy"] if !ok { return def } policy, ok := raw.(map[string]interface{}) if !ok { return def } v, ok := policy[pclass] if !ok { return def } switch n := v.(type) { case float64: return int(n) case int: return n default: return def } } // Logout 撤销指定会话(用户主动登出)。sid 为空(存量 token)时为 no-op。 func (s *AuthService) Logout(sid string) error { if sid == "" { return nil } return s.db.Model(&model.UserSession{}). Where("sid = ? AND revoked_at IS NULL", sid). Updates(map[string]interface{}{"revoked_at": time.Now(), "revoked_reason": "logout"}).Error } // SessionView 在线会话视图(含用户名,供管理端列表展示)。 type SessionView struct { ID uint64 `json:"id"` UserID uint64 `json:"user_id"` Username string `json:"username"` RealName string `json:"real_name"` Platform string `json:"platform"` PlatformClass string `json:"platform_class"` DeviceName string `json:"device_name"` IP string `json:"ip"` CreatedAt time.Time `json:"created_at"` LastSeenAt time.Time `json:"last_seen_at"` Online bool `json:"online"` IsCurrent bool `json:"is_current"` } // OnlineThreshold last_seen 在此时间内视为在线。 const OnlineThreshold = 90 * time.Second // ListSessions 列出本店活跃(未撤销)会话,按最近活跃排序。currentSID 标记当前请求自身会话。 func (s *AuthService) ListSessions(shopID uint64, currentSID string) ([]SessionView, error) { var rows []struct { model.UserSession Username string RealName string } err := s.db.Table("user_sessions AS s"). Select("s.*, u.username AS username, u.real_name AS real_name"). Joins("LEFT JOIN users u ON u.id = s.user_id"). Where("s.shop_id = ? AND s.revoked_at IS NULL", shopID). Order("s.last_seen_at DESC"). Scan(&rows).Error if err != nil { return nil, err } now := time.Now() views := make([]SessionView, 0, len(rows)) for _, r := range rows { views = append(views, SessionView{ ID: r.ID, UserID: r.UserID, Username: r.Username, RealName: r.RealName, Platform: r.Platform, PlatformClass: r.PlatformClass, DeviceName: r.DeviceName, IP: r.IP, CreatedAt: r.CreatedAt, LastSeenAt: r.LastSeenAt, Online: now.Sub(r.LastSeenAt) <= OnlineThreshold, IsCurrent: currentSID != "" && r.SID == currentSID, }) } return views, nil } // ForceLogout 管理员强制下线本店某会话(按 id + shop_id 隔离)。byUserID 记入 revoked_by 供审计。 func (s *AuthService) ForceLogout(shopID, sessionID, byUserID uint64) error { res := s.db.Model(&model.UserSession{}). Where("id = ? AND shop_id = ? AND revoked_at IS NULL", sessionID, shopID). Updates(map[string]interface{}{"revoked_at": time.Now(), "revoked_reason": "admin", "revoked_by": byUserID}) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return gorm.ErrRecordNotFound } return nil } // RevokeUserSessions 吊销某用户在本店的全部活跃会话(改密/禁用时调用)。byUserID 为操作人(记入 revoked_by)。 // db 入参以便 handler 在不持有 AuthService 时也能复用同一语义。 func RevokeUserSessions(db *gorm.DB, shopID, userID, byUserID uint64, reason string) error { updates := map[string]interface{}{"revoked_at": time.Now(), "revoked_reason": reason} if byUserID != 0 { updates["revoked_by"] = byUserID } return db.Model(&model.UserSession{}). Where("shop_id = ? AND user_id = ? AND revoked_at IS NULL", shopID, userID). Updates(updates).Error } // RegisterInput 注册新门店所需参数 type RegisterInput struct { ShopName string `json:"shop_name" binding:"required"` Address string `json:"address" binding:"required"` ManagerName string `json:"manager_name" binding:"required"` Phone string `json:"phone" binding:"required"` Description string `json:"description"` Username string `json:"username" binding:"required"` Password string `json:"password" binding:"required,min=6"` } // RegisterResult 注册成功后返回的数据 type RegisterResult struct { ShopCode string `json:"shop_code"` ShopName string `json:"shop_name"` Username string `json:"username"` } // Register 自助注册新门店(公开接口,无需认证) func (s *AuthService) Register(in RegisterInput) (*RegisterResult, error) { hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost) if err != nil { return nil, err } var result RegisterResult err = s.db.Transaction(func(tx *gorm.DB) error { // 先用 UUID 占位创建门店,得到真实 ID shop := model.Shop{ Name: in.ShopName, Code: uuid.New().String(), Address: in.Address, Phone: in.Phone, ManagerName: in.ManagerName, Description: in.Description, } if err := tx.Create(&shop).Error; err != nil { return err } // 用自增 ID 生成正式门店编码 shop.Code = fmt.Sprintf("S%06d", shop.ID) if err := tx.Model(&shop).Update("code", shop.Code).Error; err != nil { return err } // 创建管理员用户 user := model.User{ ShopID: shop.ID, Username: in.Username, PasswordHash: string(hash), RealName: in.ManagerName, Phone: in.Phone, Role: "superadmin", IsActive: true, } if err := tx.Create(&user).Error; err != nil { return err } createTrialLicense(tx, shop.ID) result = RegisterResult{ ShopCode: shop.Code, ShopName: shop.Name, Username: user.Username, } return nil }) return &result, err } // HashPassword 生成 bcrypt 哈希 func HashPassword(plain string) (string, error) { b, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost) return string(b), err } // RefreshTokens 用 Refresh Token 换新 Token Pair。 // 修复历史漏洞:续期时重新查库校验用户/会话状态,被禁用或被踢的用户无法再续命。 func (s *AuthService) RefreshTokens(refreshToken string) (*TokenPair, error) { claims := &middleware.Claims{} token, err := jwt.ParseWithClaims(refreshToken, claims, func(t *jwt.Token) (interface{}, error) { return []byte(config.C.JWT.Secret), nil }) if err != nil || !token.Valid { return nil, errors.New("invalid refresh token") } // 重新加载用户:不存在或已禁用 → 拒绝续期(修复漏洞)。 var user model.User if err := s.db.Where("id = ? AND deleted_at IS NULL", claims.UserID).First(&user).Error; err != nil { return nil, ErrInvalidCredentials } if !user.IsActive { return nil, ErrUserInactive } if err := s.checkLicenseNotLocked(user.ShopID); err != nil { return nil, err } // 带 sid 的会话:在事务内 FOR UPDATE 锁行做「校验 → 重用检测 → 轮换」(check-then-act)。 // 无 sid 的存量 token:首刷即建立可吊销会话并签发带 sid 的新 token,使其转入受控状态 //(自此可被强制下线/踢人/改密吊销),不再永久游离于会话治理之外。 newJTI := uuid.New().String() now := time.Now() sid := claims.SID if sid == "" { // 存量无 sid token:复用「该用户唯一的 legacy 会话」,而非每次续期都新建一条。 // 事务 + FOR UPDATE 锁住既有 legacy 行、串行化并发的存量续期;找不到才创建。 // 这样重复/并发呈递同一存量 token 始终只对应一条可吊销会话(有界、纳入会话治理、 // 可被强制下线/改密吊销),不再每刷一条无限膨胀。正常客户端首刷后即拿到带 sid 的新 // token,自然转入受控分支,此 legacy 行随其过期由清理任务回收。 exp := now.Add(time.Duration(config.C.JWT.RefreshExpireH) * time.Hour) if err := s.db.Transaction(func(tx *gorm.DB) error { // 锁住该用户全部活跃 legacy 会话。legacy class 不在 effectiveQuota 的 // desktop/mobile/web 映射内(会返回 0),若不在此显式约束,legacy 会话便游离于 // 并发配额之外。这里把「每用户至多一条 legacy 会话」从 find-or-create 的隐式产物 // 提升为显式上限:复用最早的一条(轮换 jti),其余多余 legacy 会话一并吊销。 var sessions []model.UserSession if err := tx.Set("gorm:query_option", "FOR UPDATE"). Where("shop_id = ? AND user_id = ? AND platform_class = ? AND revoked_at IS NULL", user.ShopID, user.ID, "legacy"). Order("id ASC").Find(&sessions).Error; err != nil { return err } if len(sessions) > 0 { keep := sessions[0] sid = keep.SID if err := tx.Model(&model.UserSession{}).Where("id = ?", keep.ID). Updates(map[string]interface{}{ "refresh_jti": newJTI, "last_seen_at": now, "refresh_exp_at": exp, }).Error; err != nil { return err } if len(sessions) > 1 { extraIDs := make([]uint64, 0, len(sessions)-1) for _, ex := range sessions[1:] { extraIDs = append(extraIDs, ex.ID) } if err := tx.Model(&model.UserSession{}).Where("id IN ?", extraIDs). Updates(map[string]interface{}{"revoked_at": now, "revoked_reason": "kicked"}).Error; err != nil { return err } } return nil } newSID := uuid.New().String() sess := model.UserSession{ ShopID: user.ShopID, UserID: user.ID, SID: newSID, Platform: "legacy", PlatformClass: "legacy", RefreshJTI: newJTI, LastSeenAt: now, RefreshExpAt: exp, } if err := tx.Create(&sess).Error; err != nil { return err } sid = newSID return nil }); err != nil { return nil, err } } else { var reuseSessID uint64 txErr := s.db.Transaction(func(tx *gorm.DB) error { var sess model.UserSession if err := tx.Set("gorm:query_option", "FOR UPDATE"). Where("sid = ?", claims.SID).First(&sess).Error; err != nil { return ErrSessionRevoked } if sess.RevokedAt != nil { return ErrSessionRevoked } // 重用检测:会话已确立 jti,但呈递的 refresh token 携带的 jti 与当前值不符 → // 说明这是已被轮换取代的旧 token 重放(盗用信号),吊销整条会话。 // 向后兼容:sess.RefreshJTI=="" 为存量会话,首刷直接采纳新 jti,不报重用。 // 注意:吊销动作放到事务外执行——此处一旦 return 非 nil 错误,事务会回滚, // 在事务内写吊销会被一并回滚掉。 if sess.RefreshJTI != "" && claims.ID != sess.RefreshJTI { reuseSessID = sess.ID return errRefreshReuse } // 轮换 jti + 刷新存活时间。 return tx.Model(&model.UserSession{}).Where("id = ?", sess.ID). Updates(map[string]interface{}{"refresh_jti": newJTI, "last_seen_at": now}).Error }) if errors.Is(txErr, errRefreshReuse) { // 事务外提交吊销(不随回滚丢失),吊销整条会话。写库失败必须记日志—— // 否则盗用信号被静默吞掉,被取代的旧 token 仍可继续续期,功能形同虚设。 if err := s.db.Model(&model.UserSession{}).Where("id = ?", reuseSessID). Updates(map[string]interface{}{"revoked_at": now, "revoked_reason": "reuse"}).Error; err != nil { log.Printf("[auth] revoke reused session %d failed: %v", reuseSessID, err) } return nil, ErrSessionRevoked } if txErr != nil { return nil, txErr } } // 自动续登(refresh)路径同样补发首次试用:老用户用本地 refresh token 自动登录、 // 从不走 Login(),否则永远停留在「未激活」。须在 issueTokens 之前,使新 JWT 的 // lic_exp 带上试用到期日。 s.ensureTrialOnFirstUse(user.ShopID) return s.issueTokens(user.ID, user.ShopID, user.Role, sid, newJTI) } // ensureTrialOnFirstUse 门店首次使用(尚无任何 is_active 授权)时自动签发 30 天 trial, // 使「未激活」门店在首次登录后即转为试用版;后续到期降级(grace/readonly/locked)链路照常生效。 // 已有有效授权(含已过期但未锁定的 trial/付费)则跳过,不重复发放。 // 签发失败仅记日志、不阻断登录(保持可用,门店维持未激活)。 func (s *AuthService) ensureTrialOnFirstUse(shopID uint64) { // 快路径:绝大多数登录/续期门店已有有效授权。先做一次无锁 Count 直接返回, // 避免每次都开事务锁 shop 行——该函数在每次 Login/RefreshTokens 都被调用, // 高频路径上的行锁会无谓串行化同店并发请求。 var count int64 if err := s.db.Model(&model.License{}). Where("shop_id = ? AND is_active = 1", shopID).Count(&count).Error; err != nil { log.Printf("[license] auto-trial precheck for shop %d failed: %v", shopID, err) return } if count > 0 { return } // 慢路径(首次使用):事务内锁住门店行后**重新** Count,串行化同店并发的「首次试用」 // 判断:无既有 license 行可锁,故锁父级 shop 行,避免两个并发请求都读到 count==0 // 各发一条 trial(重复授权)。 issued := false err := s.db.Transaction(func(tx *gorm.DB) error { var shop model.Shop if err := tx.Set("gorm:query_option", "FOR UPDATE"). Where("id = ?", shopID).First(&shop).Error; err != nil { return err } var c int64 if err := tx.Model(&model.License{}). Where("shop_id = ? AND is_active = 1", shopID).Count(&c).Error; err != nil { return err } if c > 0 { return nil } if err := issueTrialLicense(tx, shopID); err != nil { return err } issued = true return nil }) if err != nil { log.Printf("[license] auto-trial for shop %d skipped: %v", shopID, err) return } // 事务提交后再失效 phase 缓存:此刻新签发的 trial 行对其它连接已可见, // 不会在提交前的窗口里被旧 phase 重新填充(修复缓存失效早于提交的竞态)。 if issued { middleware.InvalidateLicensePhase(shopID) } } func (s *AuthService) checkLicenseNotLocked(shopID uint64) error { var lic model.License if err := s.db.Where("shop_id = ? AND is_active = 1", shopID). Order("id DESC").First(&lic).Error; err != nil { return nil // no license record → allow login } if middleware.CalcLicensePhase(lic.ExpiresAt) == middleware.PhaseLocked { return ErrLicenseLocked } return nil } func (s *AuthService) issueTokens(userID, shopID uint64, role, sid, refreshJTI string) (*TokenPair, error) { cfg := config.C.JWT now := time.Now() // Embed license expires_at in JWT so LicenseGuard can check phase without DB. var licExpAt *int64 var lic model.License if err := s.db.Where("shop_id = ? AND is_active = 1", shopID). Order("id DESC").First(&lic).Error; err == nil && lic.ExpiresAt != nil { ts := lic.ExpiresAt.Unix() licExpAt = &ts } accessExp := now.Add(time.Duration(cfg.AccessExpireMin) * time.Minute) accessClaims := middleware.Claims{ UserID: userID, ShopID: shopID, Role: role, SID: sid, LicenseExpiresAt: licExpAt, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(accessExp), IssuedAt: jwt.NewNumericDate(now), }, } accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString([]byte(cfg.Secret)) if err != nil { return nil, err } refreshExp := now.Add(time.Duration(cfg.RefreshExpireH) * time.Hour) refreshClaims := middleware.Claims{ UserID: userID, ShopID: shopID, Role: role, SID: sid, LicenseExpiresAt: licExpAt, RegisteredClaims: jwt.RegisteredClaims{ ID: refreshJTI, // jti:refresh token 轮换与重用检测的依据 ExpiresAt: jwt.NewNumericDate(refreshExp), IssuedAt: jwt.NewNumericDate(now), }, } refreshToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString([]byte(cfg.Secret)) if err != nil { return nil, err } return &TokenPair{ AccessToken: accessToken, RefreshToken: refreshToken, ExpiresIn: cfg.AccessExpireMin * 60, ShopID: shopID, }, nil }