Files
jiu/backend/internal/service/auth.go
T
wangjia 7bbc944ae2
Deploy Server / release-deploy-server (push) Successful in 51s
chore: release server-v1.0.62
服务端安全加固:多维限流(按 IP/按门店)+ 敏感接口独立速率上限抵御 DDoS/刷接口;
登录暴力破解新增按来源 IP 锁定;反代后正确识别真实客户端 IP;
门店 custom_fields 轻量配置(录入默认值)透传保存。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Wdwo7SmgBJU37cBrkhPK
2026-06-19 20:20:44 +08:00

727 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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("该平台不允许登录")
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="<shopCode>|<username>"IP 维度 key="ip|<addr>"
// 分别用不同阈值锁定。带 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=禁止该平台,超额则踢最旧会话腾位。
pclass := model.PlatformClass(dev.Platform)
quota := s.effectiveQuota(&shop, pclass)
if quota <= 0 {
s.recordLoginAttempt(shopCode, username, dev, false, "platform_not_allowed")
return nil, nil, ErrPlatformNotAllowed
}
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 在该 class 的活跃会话;超额踢最旧
var active []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",
shop.ID, user.ID, pclass).
Order("last_seen_at ASC").Find(&active).Error; err != nil {
return err
}
// 需要腾出 (active+1) - quota 个位置
for i := 0; i <= len(active)-quota; i++ {
if err := tx.Model(&model.UserSession{}).Where("id = ?", active[i].ID).
Updates(map[string]interface{}{"revoked_at": now, "revoked_reason": "kicked"}).Error; err != nil {
return err
}
}
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 {
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)
}
}
// effectiveQuota 返回某店某平台类的有效并发配额:优先每店 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, // jtirefresh 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
}