feat: 客户端授权防护——响应签名/激活令牌/宽限期/换座限制/在线清扫/疑似共享标记

This commit is contained in:
Logan.Li committed 2026-09-14 21:08:39 +08:00
1 parent 37d9e8ca56
commit b76111dfc3
11 files changed
+437 -54

No files matched your search

@@ -29,40 +29,73 @@ func inferPlanType(lic model.License) string {
func licenseClientPayload(lic model.License) map[string]interface{} {
return map[string]interface{}{
"license_key": lic.LicenseKey, // 签名字段:客户端凭响应重建验签串,必须自带 // Logan.Li
"expires_at": lic.EndDate.Format("2006-01-02 15:04:05"),
"days_left": utils.CalculateDaysLeft(lic.EndDate),
"max_devices": lic.MaxActivations,
"status": lic.Status,
"plan_type": inferPlanType(lic),
}
}
// Verify 客户端 License 验证 // Logan.Li
// Verify 客户端 License 验证(含过期宽限与响应签名) // Logan.Li
func (s *LicenseService) Verify(req dto.LicenseVerifyRequest) (*dto.LicenseVerifyResponse, error) {
var lic model.License
if err := s.db.Preload("Product").Where("license_key = ?", req.LicenseKey).First(&lic).Error; err != nil {
return &dto.LicenseVerifyResponse{Valid: false, Message: "无效的 License"}, nil
resp := &dto.LicenseVerifyResponse{Valid: false, Message: "无效的 License"}
s.signVerifyResponse(resp)
return resp, nil
}
if req.ProductID > 0 && lic.ProductID != req.ProductID {
return &dto.LicenseVerifyResponse{Valid: false, Message: "License 与产品不匹配"}, nil
resp := &dto.LicenseVerifyResponse{Valid: false, Message: "License 与产品不匹配"}
s.signVerifyResponse(resp)
return resp, nil
}
// 吊销/暂停是滥用处置,立即生效、无宽限 // Logan.Li
switch lic.Status {
case model.LicenseStatusRevoked:
return &dto.LicenseVerifyResponse{Valid: false, Message: "License 已被吊销"}, nil
resp := &dto.LicenseVerifyResponse{Valid: false, Status: lic.Status, Message: "License 已被吊销"}
s.signVerifyResponse(resp)
return resp, nil
case model.LicenseStatusSuspended:
return &dto.LicenseVerifyResponse{Valid: false, Message: "License 已被暂停"}, nil
case model.LicenseStatusExpired:
return &dto.LicenseVerifyResponse{Valid: false, Message: "License 已过期"}, nil
resp := &dto.LicenseVerifyResponse{Valid: false, Status: lic.Status, Message: "License 已被暂停"}
s.signVerifyResponse(resp)
return resp, nil
}
if utils.IsLicenseExpired(lic.EndDate) {
lic.Status = model.LicenseStatusExpired
s.db.Save(&lic)
return &dto.LicenseVerifyResponse{Valid: false, Message: "License 已过期"}, nil
// 商业过期:宽限期内放行并提示续费,超出宽限才拒绝 // Logan.Li
if lic.Status == model.LicenseStatusExpired || utils.IsLicenseExpired(lic.EndDate) {
if lic.Status != model.LicenseStatusExpired {
lic.Status = model.LicenseStatusExpired
s.db.Save(&lic)
}
if inGrace, graceDays := s.inGraceInfo(lic); inGrace {
resp := &dto.LicenseVerifyResponse{
Valid: true,
LicenseKey: lic.LicenseKey,
ProductID: lic.ProductID,
ProductName: lic.Product.Name,
ProductCode: lic.Product.Code,
Features: utils.ParseFeatures(lic.Product.Features),
Status: lic.Status,
ExpiresAt: lic.EndDate.Format("2006-01-02 15:04:05"),
MaxDevices: lic.MaxActivations,
PlanType: inferPlanType(lic),
Message: "License 已过期,处于宽限期,请尽快续费",
InGrace: true,
GraceDaysLeft: graceDays,
}
s.signVerifyResponse(resp)
return resp, nil
}
resp := &dto.LicenseVerifyResponse{Valid: false, Status: lic.Status, Message: "License 已过期"}
s.signVerifyResponse(resp)
return resp, nil
}
return &dto.LicenseVerifyResponse{
resp := &dto.LicenseVerifyResponse{
Valid: true,
LicenseKey: lic.LicenseKey,
ProductID: lic.ProductID,
@@ -75,12 +108,16 @@ func (s *LicenseService) Verify(req dto.LicenseVerifyRequest) (*dto.LicenseVerif
MaxDevices: lic.MaxActivations,
PlanType: inferPlanType(lic),
Message: "验证成功",
}, nil
}
s.signVerifyResponse(resp)
return resp, nil
}
// Activate 客户端激活 License(事务 + 条件占座,消除 TOCTOU) // Logan.Li
// Activate 客户端激活 License(事务 + 条件占座,消除 TOCTOU);
// 新设备激活时下发设备激活令牌,并对响应签名。 // Logan.Li
func (s *LicenseService) Activate(req dto.LicenseActivateRequest, clientIP string) (map[string]interface{}, error) {
var payload map[string]interface{}
var newToken string
err := s.db.Transaction(func(tx *gorm.DB) error {
var lic model.License
if err := tx.Where("license_key = ?", req.LicenseKey).First(&lic).Error; err != nil {
@@ -126,6 +163,11 @@ func (s *LicenseService) Activate(req dto.LicenseActivateRequest, clientIP strin
return nil
}
// 新设备占座前校验 24 小时换座上限,抑制序列号共享轮换 // Logan.Li
if err := s.checkDailyActivationLimit(tx, lic.ID); err != nil {
return err
}
res := tx.Model(&model.License{}).
Where("id = ? AND current_activations < max_activations", lic.ID).
Updates(map[string]interface{}{
@@ -139,6 +181,7 @@ func (s *LicenseService) Activate(req dto.LicenseActivateRequest, clientIP strin
return errors.New("已达到最大激活数量限制")
}
newToken = generateActivationToken()
if err := tx.Create(&model.Activation{
LicenseID: lic.ID,
DeviceID: req.DeviceID,
@@ -147,6 +190,7 @@ func (s *LicenseService) Activate(req dto.LicenseActivateRequest, clientIP strin
IP: clientIP,
Status: "active",
IsOnline: true,
Token: newToken,
ActivatedAt: now,
LastSeenAt: now,
}).Error; err != nil {
@@ -161,16 +205,31 @@ func (s *LicenseService) Activate(req dto.LicenseActivateRequest, clientIP strin
if err != nil {
return nil, err
}
if newToken != "" {
// 令牌仅在本机首次占座时下发一次;客户端须持久保存用于后续解绑 // Logan.Li
payload["activation_token"] = newToken
}
s.signPayloadMap(payload, req.LicenseKey)
return payload, nil
}
// Deactivate 客户端停用(仅 active→inactive 才减席位) // Logan.Li
// Deactivate 客户端停用(仅 active→inactive 才减席位);
// 新设备记录强制校验激活令牌,防止仅凭序列号偷座/踢座。 // Logan.Li
func (s *LicenseService) Deactivate(req dto.LicenseDeactivateRequest) error {
return s.db.Transaction(func(tx *gorm.DB) error {
var lic model.License
if err := tx.Where("license_key = ?", req.LicenseKey).First(&lic).Error; err != nil {
return errors.New("无效的 License")
}
var act model.Activation
hasRow := tx.Where("license_id = ? AND device_id = ?", lic.ID, req.DeviceID).
First(&act).Error == nil
// 存量记录 Token 为空(升级前激活),保持旧协议放行以兼容老客户端 // Logan.Li
if hasRow && act.Token != "" && s.guard != nil && s.guard.deactivateRequireToken {
if req.ActivationToken == "" || req.ActivationToken != act.Token {
return errors.New("解绑校验失败:请在对应设备的工具内解绑,或联系管理员处理")
}
}
res := tx.Model(&model.Activation{}).
Where("license_id = ? AND device_id = ? AND status = ?", lic.ID, req.DeviceID, "active").
Updates(map[string]interface{}{"status": "inactive", "is_online": false})
@@ -186,7 +245,7 @@ func (s *LicenseService) Deactivate(req dto.LicenseDeactivateRequest) error {
})
}
// Heartbeat 客户端心跳 // Logan.Li
// Heartbeat 客户端心跳(含宽限期放行与响应签名) // Logan.Li
func (s *LicenseService) Heartbeat(req dto.LicenseHeartbeatRequest) (map[string]interface{}, error) {
var lic model.License
if err := s.db.Where("license_key = ?", req.LicenseKey).First(&lic).Error; err != nil {
@@ -197,6 +256,15 @@ func (s *LicenseService) Heartbeat(req dto.LicenseHeartbeatRequest) (map[string]
return nil, errors.New("License 已被吊销或暂停")
}
if lic.Status == model.LicenseStatusExpired || utils.IsLicenseExpired(lic.EndDate) {
// 宽限期内心跳照常放行,超出宽限才中断 // Logan.Li
if inGrace, graceDays := s.inGraceInfo(lic); inGrace {
payload := licenseClientPayload(lic)
payload["in_grace"] = true
payload["grace_days_left"] = graceDays
payload["message"] = "License 已过期,处于宽限期,请尽快续费"
s.signPayloadMap(payload, req.LicenseKey)
return payload, nil
}
return nil, errors.New("License 已过期")
}
@@ -204,7 +272,9 @@ func (s *LicenseService) Heartbeat(req dto.LicenseHeartbeatRequest) (map[string]
Where("license_id = ? AND device_id = ?", lic.ID, req.DeviceID).
Updates(map[string]interface{}{"last_seen_at": time.Now(), "is_online": true})
return licenseClientPayload(lic), nil
payload := licenseClientPayload(lic)
s.signPayloadMap(payload, req.LicenseKey)
return payload, nil
}
// ListDevicesByKey returns activations for a license key (client self-service). // Logan.Li