e41085a878
会话安全(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>
241 lines
8.2 KiB
Go
241 lines
8.2 KiB
Go
package service
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/base32"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"strings"
|
|
"time"
|
|
|
|
"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"
|
|
)
|
|
|
|
var (
|
|
ErrLicenseNotFound = errors.New("license not found")
|
|
ErrLicenseInactive = errors.New("license is inactive")
|
|
ErrLicenseExpired = errors.New("license has expired")
|
|
ErrDeviceLimitExceed = errors.New("device limit reached — deactivate another device first")
|
|
)
|
|
|
|
type LicenseService struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func NewLicenseService(db *gorm.DB) *LicenseService {
|
|
return &LicenseService{db: db}
|
|
}
|
|
|
|
// GenerateKey 生成许可证激活码
|
|
// 格式:HMAC-SHA256(shopID+licenseType+expiry, secret) → base32, 每5字符加'-'
|
|
func GenerateKey(shopID uint64, licenseType string, expiresAt *time.Time) string {
|
|
payload := fmt.Sprintf("%d:%s", shopID, licenseType)
|
|
if expiresAt != nil {
|
|
payload += ":" + expiresAt.Format("20060102")
|
|
}
|
|
mac := hmac.New(sha256.New, []byte(config.C.License.HMACSecret))
|
|
mac.Write([]byte(payload))
|
|
raw := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(mac.Sum(nil))
|
|
// 截取前20字符,分4段,每段5字符
|
|
raw = strings.ToUpper(raw)[:20]
|
|
return fmt.Sprintf("%s-%s-%s-%s", raw[0:5], raw[5:10], raw[10:15], raw[15:20])
|
|
}
|
|
|
|
// Activate 激活许可证并绑定设备到 license_devices 表。
|
|
// 若该设备已绑定,则更新 last_seen_at(幂等)。
|
|
// 若是新设备,则校验是否超出 max_devices 上限。
|
|
func (s *LicenseService) Activate(shopID uint64, licenseKey, deviceID, deviceName, platform string) (*model.License, error) {
|
|
var lic model.License
|
|
if err := s.db.Where("license_key = ? AND shop_id = ?", licenseKey, shopID).First(&lic).Error; err != nil {
|
|
return nil, ErrLicenseNotFound
|
|
}
|
|
if !lic.IsActive {
|
|
return nil, ErrLicenseInactive
|
|
}
|
|
if lic.ExpiresAt != nil && time.Now().After(*lic.ExpiresAt) {
|
|
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 {
|
|
// Device already bound — just touch last_seen_at (handled by autoUpdateTime)
|
|
if err := s.db.Model(&existing).Update("device_name", deviceName).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &lic, nil
|
|
}
|
|
|
|
// New device — enforce max_devices
|
|
var count int64
|
|
if err := s.db.Model(&model.LicenseDevice{}).Where("license_id = ?", lic.ID).Count(&count).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if int(count) >= lic.MaxDevices {
|
|
return nil, ErrDeviceLimitExceed
|
|
}
|
|
|
|
dev := model.LicenseDevice{
|
|
LicenseID: lic.ID,
|
|
ShopID: shopID,
|
|
DeviceID: deviceID,
|
|
DeviceName: deviceName,
|
|
Platform: platform,
|
|
}
|
|
if err := s.db.Create(&dev).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &lic, nil
|
|
}
|
|
|
|
// Verify 验证设备许可证(客户端启动时调用)。
|
|
// 通过 license_devices 表查找设备,再加载对应的许可证做有效性检查。
|
|
func (s *LicenseService) Verify(shopID uint64, deviceID string) (*model.License, error) {
|
|
var dev model.LicenseDevice
|
|
if err := s.db.Where("shop_id = ? AND device_id = ?", shopID, deviceID).First(&dev).Error; err != nil {
|
|
return nil, ErrLicenseNotFound
|
|
}
|
|
var lic model.License
|
|
if err := s.db.Where("id = ? AND shop_id = ? AND is_active = 1", dev.LicenseID, shopID).First(&lic).Error; err != nil {
|
|
return nil, ErrLicenseNotFound
|
|
}
|
|
if lic.ExpiresAt != nil && time.Now().After(*lic.ExpiresAt) {
|
|
return nil, ErrLicenseExpired
|
|
}
|
|
return &lic, nil
|
|
}
|
|
|
|
// ShopInfo 返回门店当前授权信息(取最新一条有效许可证)
|
|
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 {
|
|
// 仅「确无记录」才算无授权;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
|
|
err := s.db.Model(&model.LicenseDevice{}).Where("license_id = ?", licenseID).Count(&count).Error
|
|
return count, err
|
|
}
|
|
|
|
// ListDevices 列出许可证下所有已绑定设备。
|
|
func (s *LicenseService) ListDevices(shopID uint64) ([]model.LicenseDevice, error) {
|
|
var devs []model.LicenseDevice
|
|
if err := s.db.Where("shop_id = ?", shopID).Order("activated_at DESC").Find(&devs).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return devs, nil
|
|
}
|
|
|
|
// Deactivate 解绑设备(从 license_devices 删除该条记录)。
|
|
func (s *LicenseService) Deactivate(shopID uint64, deviceID string) error {
|
|
return s.db.Where("shop_id = ? AND device_id = ?", shopID, deviceID).
|
|
Delete(&model.LicenseDevice{}).Error
|
|
}
|
|
|
|
// issueTrialLicense 为门店签发并写入一条 30 天 trial license。
|
|
// 私钥未配置或签发/落库失败时返回 error,由调用方决定如何处理(注册路径 Fatal、
|
|
// 登录路径仅记日志)。
|
|
func issueTrialLicense(db *gorm.DB, shopID uint64) error {
|
|
privKey := config.C.License.Ed25519PrivateKey
|
|
if privKey == "" {
|
|
return fmt.Errorf("ed25519 private key not configured")
|
|
}
|
|
|
|
expiresAt := time.Now().Add(30 * 24 * time.Hour)
|
|
expiresUnix := expiresAt.Unix()
|
|
payload := util.LicensePayload{
|
|
ShopID: shopID,
|
|
Type: "trial",
|
|
IssuedAt: time.Now().Unix(),
|
|
ExpiresAt: &expiresUnix,
|
|
MaxDevices: 1,
|
|
}
|
|
token, err := util.IssueLicenseToken(payload, privKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
lic := model.License{
|
|
ShopID: shopID,
|
|
LicenseKey: token,
|
|
Type: "trial",
|
|
ExpiresAt: &expiresAt,
|
|
IsActive: true,
|
|
MaxDevices: 1,
|
|
}
|
|
if err := db.Create(&lic).Error; err != nil {
|
|
return err
|
|
}
|
|
// 注意:phase 缓存失效不在此处做——本函数运行在调用方事务内,提交前失效会留下
|
|
// 30s 窗口:并发请求可能在新 license 行可见前用旧 phase 重新填充缓存。
|
|
// 失效改由调用方在事务提交后执行(见 ensureTrialOnFirstUse)。
|
|
return nil
|
|
}
|
|
|
|
// createTrialLicense 在注册事务中为新门店签发 30 天 trial license。
|
|
// 私钥未配置时 Fatal,防止生产环境静默跳过导致新注册门店无 license。
|
|
func createTrialLicense(tx *gorm.DB, shopID uint64) {
|
|
if config.C.License.Ed25519PrivateKey == "" {
|
|
log.Fatalf("[license] Ed25519 private key not configured — cannot issue trial for shop %d; set License.Ed25519PrivateKey in config", shopID)
|
|
}
|
|
if err := issueTrialLicense(tx, shopID); err != nil {
|
|
log.Printf("[license] failed to create trial license for shop %d: %v", shopID, err)
|
|
}
|
|
}
|