feat(backend): 会话安全加固 + 授权实时 phase + 首次使用自动试用
会话安全(jti 轮换 / 重用检测 / 改密吊销 / 禁用即时下线 / 清理 / 失败登录落库): - refresh token 轮换 jti + token-family 重用检测,旧 token 重放即吊销整条会话 - 改密码、停用用户即时吊销其全部活跃会话(revoked_by 审计) - 中间件 session JOIN user 校验,禁用/删除用户带 token 请求返回 401 USER_DISABLED - 新增 login_attempts 失败登录落库 + 会话保留期清理 goroutine 授权实时 phase + 心跳回带: - LicenseGuard 改为按当前 DB 实时计算 phase(30s 每店缓存),续费/过期/被改 ~30s 内对写操作生效,无需重登 - /auth/ping 回带授权概况(ShopInfoView,与 /license/info 同构),客户端一次心跳即刷新横幅/门禁 首次使用自动试用 + code-review 修复: - 门店首次登录/续期无有效授权时自动签发 30 天 trial(快路径无锁 Count,仅首用走 FOR UPDATE 事务) - ShopInfo 区分「确无授权」与瞬时 DB 错误,避免误降级 - trial 签发后改为在事务提交后再失效 phase 缓存(修复早于提交的竞态) - 存量无 sid token 续期纳入显式上限,legacy 会话不再游离于并发配额之外 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/wangjia/jiu/backend/config"
|
||||
"github.com/wangjia/jiu/backend/internal/middleware"
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
"github.com/wangjia/jiu/backend/internal/util"
|
||||
)
|
||||
@@ -62,6 +63,9 @@ func (s *LicenseService) Activate(shopID uint64, licenseKey, deviceID, deviceNam
|
||||
return nil, ErrLicenseExpired
|
||||
}
|
||||
|
||||
// 激活成功即清除该店 phase 缓存:续费/换新授权码后写权限即时恢复,不必等 30s TTL。
|
||||
defer middleware.InvalidateLicensePhase(shopID)
|
||||
|
||||
var existing model.LicenseDevice
|
||||
err := s.db.Where("license_id = ? AND device_id = ?", lic.ID, deviceID).First(&existing).Error
|
||||
if err == nil {
|
||||
@@ -116,11 +120,52 @@ func (s *LicenseService) ShopInfo(shopID uint64) (*model.License, 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, ErrLicenseNotFound
|
||||
// 仅「确无记录」才算无授权;DB 不可达等瞬时错误必须上抛,
|
||||
// 否则会被误判为「门店无授权」,把客户端横幅/门禁错误降级。
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrLicenseNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &lic, nil
|
||||
}
|
||||
|
||||
// LicenseInfoView 门店授权概况(含设备数与实时 phase),供 /license/info 与心跳 /auth/ping 复用,
|
||||
// 保证两条路径返回结构一致。
|
||||
type LicenseInfoView struct {
|
||||
ID uint64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
IsActive bool `json:"is_active"`
|
||||
MaxDevices int `json:"max_devices"`
|
||||
DeviceCount int64 `json:"device_count"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
Phase string `json:"phase"`
|
||||
}
|
||||
|
||||
// ShopInfoView 返回门店授权概况;无有效授权时返回 (nil, nil),仅在统计设备数等查询出错时返回 error。
|
||||
func (s *LicenseService) ShopInfoView(shopID uint64) (*LicenseInfoView, error) {
|
||||
lic, err := s.ShopInfo(shopID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrLicenseNotFound) {
|
||||
return nil, nil // 确无有效授权
|
||||
}
|
||||
return nil, err // 瞬时错误上抛:Ping 据此省略 license 字段,客户端保留上次状态
|
||||
}
|
||||
count, err := s.CountDevices(lic.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &LicenseInfoView{
|
||||
ID: lic.ID,
|
||||
Type: lic.Type,
|
||||
IsActive: lic.IsActive,
|
||||
MaxDevices: lic.MaxDevices,
|
||||
DeviceCount: count,
|
||||
ExpiresAt: lic.ExpiresAt,
|
||||
Phase: middleware.CalcLicensePhase(lic.ExpiresAt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CountDevices 返回指定 license 下已绑定设备数。
|
||||
func (s *LicenseService) CountDevices(licenseID uint64) (int64, error) {
|
||||
var count int64
|
||||
@@ -174,7 +219,13 @@ func issueTrialLicense(db *gorm.DB, shopID uint64) error {
|
||||
IsActive: true,
|
||||
MaxDevices: 1,
|
||||
}
|
||||
return db.Create(&lic).Error
|
||||
if err := db.Create(&lic).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 注意:phase 缓存失效不在此处做——本函数运行在调用方事务内,提交前失效会留下
|
||||
// 30s 窗口:并发请求可能在新 license 行可见前用旧 phase 重新填充缓存。
|
||||
// 失效改由调用方在事务提交后执行(见 ensureTrialOnFirstUse)。
|
||||
return nil
|
||||
}
|
||||
|
||||
// createTrialLicense 在注册事务中为新门店签发 30 天 trial license。
|
||||
|
||||
Reference in New Issue
Block a user