Files
jiu/backend/internal/service/license.go
T
wangjia e41085a878 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>
2026-06-19 07:34:04 +08:00

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)
}
}