feat: 客户端授权防护——响应签名/激活令牌/宽限期/换座限制/在线清扫/疑似共享标记
This commit is contained in:
1 parent
37d9e8ca56
commit
b76111dfc3
11 files changed
+437
-54
No files matched your search
+46
-11
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/base64"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
@@ -30,6 +31,12 @@ type Config struct {
|
||||
// 离线许可证 Ed25519 seed(32 字节 hex;生产必须固定,勿轮换除非同步桌面公钥) // Logan.Li
|
||||
OfflineLicenseSeed string
|
||||
|
||||
// 客户端授权防护配置 // Logan.Li
|
||||
GracePeriodDays int // 过期宽限天数(仅商业过期;吊销/暂停无宽限)
|
||||
ActivateDailyLimit int // 单序列号 24 小时内新设备激活上限(0 = 不限制)
|
||||
OnlineTTLMinutes int // 激活记录在线状态有效期(last_seen 超过即视为离线)
|
||||
DeactivateRequireToken bool // 解绑是否强制校验设备激活令牌
|
||||
|
||||
// CORS 配置
|
||||
CORSOrigins []string
|
||||
}
|
||||
@@ -42,17 +49,21 @@ func Load() *Config {
|
||||
_ = godotenv.Load("../../.env")
|
||||
|
||||
cfg := &Config{
|
||||
Port: getEnv("PORT", "9001"),
|
||||
Mode: getEnv("GIN_MODE", "debug"),
|
||||
DBDriver: getEnv("DB_DRIVER", "sqlite"),
|
||||
DBHost: getEnv("DB_HOST", "127.0.0.1"),
|
||||
DBPort: getEnv("DB_PORT", "3306"),
|
||||
DBUser: getEnv("DB_USER", "root"),
|
||||
DBPassword: getEnv("DB_PASSWORD", ""),
|
||||
DBName: getEnv("DB_NAME", "db.sqlite3"),
|
||||
JWTSecret: getEnv("JWT_SECRET", ""),
|
||||
OfflineLicenseSeed: getEnv("OFFLINE_LICENSE_SEED", ""),
|
||||
CORSOrigins: defaultCORSOrigins(),
|
||||
Port: getEnv("PORT", "9001"),
|
||||
Mode: getEnv("GIN_MODE", "debug"),
|
||||
DBDriver: getEnv("DB_DRIVER", "sqlite"),
|
||||
DBHost: getEnv("DB_HOST", "127.0.0.1"),
|
||||
DBPort: getEnv("DB_PORT", "3306"),
|
||||
DBUser: getEnv("DB_USER", "root"),
|
||||
DBPassword: getEnv("DB_PASSWORD", ""),
|
||||
DBName: getEnv("DB_NAME", "db.sqlite3"),
|
||||
JWTSecret: getEnv("JWT_SECRET", ""),
|
||||
OfflineLicenseSeed: getEnv("OFFLINE_LICENSE_SEED", ""),
|
||||
GracePeriodDays: getEnvInt("GRACE_PERIOD_DAYS", 15),
|
||||
ActivateDailyLimit: getEnvInt("ACTIVATE_DAILY_LIMIT", 5),
|
||||
OnlineTTLMinutes: getEnvInt("ONLINE_TTL_MINUTES", 11520), // 默认 8 天,适配周检节奏
|
||||
DeactivateRequireToken: getEnvBool("DEACTIVATE_REQUIRE_TOKEN", true),
|
||||
CORSOrigins: defaultCORSOrigins(),
|
||||
}
|
||||
|
||||
if raw := strings.TrimSpace(os.Getenv("CORS_ORIGINS")); raw != "" {
|
||||
@@ -122,3 +133,27 @@ func getEnv(key, defaultValue string) string {
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// getEnvInt 读取整型环境变量,不存在或非法时返回默认值 // Logan.Li
|
||||
func getEnvInt(key string, defaultValue int) int {
|
||||
if raw := strings.TrimSpace(os.Getenv(key)); raw != "" {
|
||||
if v, err := strconv.Atoi(raw); err == nil {
|
||||
return v
|
||||
}
|
||||
log.Printf("[配置] %s 不是合法整数,使用默认值 %d", key, defaultValue)
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// getEnvBool 读取布尔环境变量(true/false/1/0),不存在或非法时返回默认值 // Logan.Li
|
||||
func getEnvBool(key string, defaultValue bool) bool {
|
||||
if raw := strings.TrimSpace(os.Getenv(key)); raw != "" {
|
||||
switch strings.ToLower(raw) {
|
||||
case "true", "1", "yes":
|
||||
return true
|
||||
case "false", "0", "no":
|
||||
return false
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
@@ -22,7 +22,7 @@ type LicenseController struct {
|
||||
// NewLicenseController 创建控制器实例 // Logan.Li
|
||||
func NewLicenseController(db *gorm.DB, cfg *config.Config) *LicenseController {
|
||||
return &LicenseController{
|
||||
svc: licenseservice.NewLicenseService(db),
|
||||
svc: licenseservice.NewLicenseService(db, cfg),
|
||||
offline: licenseservice.NewOfflineIssuer(db, cfg),
|
||||
db: db,
|
||||
}
|
||||
@@ -162,7 +162,7 @@ func (ctrl *LicenseController) BatchDelete(c *gin.Context) {
|
||||
utils.LogAction(ctrl.db, c, "batch_delete", "license", 0, "", "", "",
|
||||
"批量删除 License deleted="+strconv.Itoa(result.Deleted)+" skipped="+strconv.Itoa(result.Skipped))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"code": 0,
|
||||
"message": "批量删除完成",
|
||||
"data": dto.LicenseBatchDeleteResponse{
|
||||
Deleted: result.Deleted,
|
||||
|
||||
@@ -53,6 +53,9 @@ type LicenseResponse struct {
|
||||
DaysLeft int `json:"days_left"`
|
||||
Notes string `json:"notes"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
DistinctIPs int `json:"distinct_ips"` // 激活记录中的不重复 IP 数(疑似共享信号)
|
||||
NewDevices7d int `json:"new_devices_7d"` // 近 7 天新增设备数(换座频次信号)
|
||||
AbuseSuspected bool `json:"abuse_suspected"` // 疑似共享/滥用标记
|
||||
}
|
||||
|
||||
// ============ 客户端授权契约(无需认证) ============
|
||||
@@ -68,18 +71,23 @@ type LicenseVerifyRequest struct {
|
||||
|
||||
// LicenseVerifyResponse 验证响应 // Logan.Li
|
||||
type LicenseVerifyResponse struct {
|
||||
Valid bool `json:"valid"`
|
||||
LicenseKey string `json:"license_key,omitempty"`
|
||||
ProductID uint `json:"product_id,omitempty"`
|
||||
ProductName string `json:"product_name,omitempty"`
|
||||
ProductCode string `json:"product_code,omitempty"`
|
||||
Features []string `json:"features,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
DaysLeft int `json:"days_left,omitempty"`
|
||||
MaxDevices int `json:"max_devices,omitempty"`
|
||||
PlanType string `json:"plan_type,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Valid bool `json:"valid"`
|
||||
LicenseKey string `json:"license_key,omitempty"`
|
||||
ProductID uint `json:"product_id,omitempty"`
|
||||
ProductName string `json:"product_name,omitempty"`
|
||||
ProductCode string `json:"product_code,omitempty"`
|
||||
Features []string `json:"features,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
DaysLeft int `json:"days_left,omitempty"`
|
||||
MaxDevices int `json:"max_devices,omitempty"`
|
||||
PlanType string `json:"plan_type,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
InGrace bool `json:"in_grace,omitempty"` // 宽限期内(仅商业过期,吊销/暂停无宽限)
|
||||
GraceDaysLeft int `json:"grace_days_left,omitempty"` // 宽限剩余天数
|
||||
ExpiresAtUnix int64 `json:"expires_at_unix,omitempty"` // 到期时间 unix 秒(签名字段,客户端直接回用免时区换算)
|
||||
ServerTime int64 `json:"server_time,omitempty"` // 服务端时间(unix 秒),客户端据此防时钟回拨
|
||||
Sig string `json:"sig,omitempty"` // Ed25519 签名(hex),客户端用内嵌公钥验签防假服务器
|
||||
}
|
||||
|
||||
// LicenseActivateRequest 激活请求(客户端调用) // Logan.Li
|
||||
@@ -92,8 +100,9 @@ type LicenseActivateRequest struct {
|
||||
|
||||
// LicenseDeactivateRequest 停用请求(客户端调用) // Logan.Li
|
||||
type LicenseDeactivateRequest struct {
|
||||
LicenseKey string `json:"license_key" binding:"required"`
|
||||
DeviceID string `json:"device_id" binding:"required"`
|
||||
LicenseKey string `json:"license_key" binding:"required"`
|
||||
DeviceID string `json:"device_id" binding:"required"`
|
||||
ActivationToken string `json:"activation_token"` // 本机激活时下发的令牌;解绑他机需联系管理员
|
||||
}
|
||||
|
||||
// LicenseHeartbeatRequest 心跳请求(客户端调用) // Logan.Li
|
||||
@@ -123,7 +132,7 @@ type OfflineLicenseCreate struct {
|
||||
CustomerID uint `json:"customer_id"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
ProductCode string `json:"product_code"`
|
||||
PlanType string `json:"plan_type"` // trial | yearly(禁止 perpetual)
|
||||
PlanType string `json:"plan_type"` // trial | yearly(禁止 perpetual)
|
||||
DurationDays int `json:"duration_days"` // 1–365,默认 365;trial 上限 180
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
@@ -142,4 +151,3 @@ type OfflineLicenseResponse struct {
|
||||
LicenseID uint `json:"license_id"`
|
||||
PublicKey string `json:"public_key_hex"`
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ func (License) TableName() string {
|
||||
}
|
||||
|
||||
// Activation 激活记录模型(机器绑定) // Logan.Li
|
||||
// Activation 设备激活记录 // Logan.Li
|
||||
type Activation struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
LicenseID uint `json:"license_id" gorm:"uniqueIndex:idx_lic_device,priority:1"`
|
||||
@@ -70,6 +71,7 @@ type Activation struct {
|
||||
IP string `json:"ip" gorm:"size:50"`
|
||||
Status string `json:"status" gorm:"size:20;default:'active'"` // active / inactive
|
||||
IsOnline bool `json:"is_online" gorm:"default:false"`
|
||||
Token string `json:"-" gorm:"size:64"` // 设备激活令牌:解绑需持有,防止仅凭序列号偷座 // Logan.Li
|
||||
ActivatedAt time.Time `json:"activated_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
LastSeenAt time.Time `json:"last_seen_at"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package license
|
||||
|
||||
import (
|
||||
"backend/internal/dto"
|
||||
"backend/internal/model"
|
||||
"backend/utils"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// clientRespTimeLayout 客户端响应中 expires_at 的统一时间格式 // Logan.Li
|
||||
const clientRespTimeLayout = "2006-01-02 15:04:05"
|
||||
|
||||
// errDailyActivationLimit 单序列号 24 小时新设备激活超限(防换座轮换共享) // Logan.Li
|
||||
var errDailyActivationLimit = errors.New("该序列号 24 小时内新设备激活数已达上限,请明日再试或联系客服")
|
||||
|
||||
// graceDeadline 计算宽限期截止时间 = 授权到期时间 + 配置宽限天数 // Logan.Li
|
||||
func (s *LicenseService) graceDeadline(lic model.License) time.Time {
|
||||
return lic.EndDate.AddDate(0, 0, s.guard.gracePeriodDays)
|
||||
}
|
||||
|
||||
// guard 是客户端授权防护配置与签名密钥的聚合,避免 LicenseService 字段膨胀 // Logan.Li
|
||||
type clientGuard struct {
|
||||
kp *utils.OfflineKeyPair
|
||||
gracePeriodDays int
|
||||
activateDailyLimit int
|
||||
onlineTTLMinutes int
|
||||
deactivateRequireToken bool
|
||||
}
|
||||
|
||||
// inGraceInfo 判断授权是否处于宽限期,返回(是否在宽限期内, 宽限剩余天数)。
|
||||
// 仅适用于商业过期;吊销/暂停不调用本函数,直接拒绝且无宽限。 // Logan.Li
|
||||
func (s *LicenseService) inGraceInfo(lic model.License) (bool, int) {
|
||||
deadline := s.graceDeadline(lic)
|
||||
if time.Now().After(deadline) {
|
||||
return false, 0
|
||||
}
|
||||
days := int(time.Until(deadline).Hours() / 24)
|
||||
if days < 0 {
|
||||
days = 0
|
||||
}
|
||||
return true, days
|
||||
}
|
||||
|
||||
// signClientResponse 为客户端验证/心跳响应生成服务端时间与 Ed25519 签名。
|
||||
// 签名串为固定顺序的竖线拼接(expires 用 unix 秒,随响应一并下发,客户端免时区换算):
|
||||
// v1|license_key|valid|status|expires_at_unix|server_time|in_grace|max_devices|plan_type
|
||||
// 桌面端按同序重组即可验签(防假服务器/防篡改缓存)。
|
||||
// 未配置离线 seed 时返回零值,响应退化为无签名字段。 // Logan.Li
|
||||
func (s *LicenseService) signClientResponse(licenseKey string, valid bool, status string,
|
||||
expiresUnix int64, inGrace bool, maxDevices int, planType string) (int64, string) {
|
||||
|
||||
if s.guard == nil || s.guard.kp == nil {
|
||||
return 0, ""
|
||||
}
|
||||
serverTime := time.Now().Unix()
|
||||
msg := "v1|" + licenseKey + "|" +
|
||||
strconv.FormatBool(valid) + "|" +
|
||||
status + "|" +
|
||||
strconv.FormatInt(expiresUnix, 10) + "|" +
|
||||
strconv.FormatInt(serverTime, 10) + "|" +
|
||||
strconv.FormatBool(inGrace) + "|" +
|
||||
strconv.Itoa(maxDevices) + "|" +
|
||||
planType
|
||||
return serverTime, s.guard.kp.SignHexString(msg)
|
||||
}
|
||||
|
||||
// parseClientRespTime 解析响应中的 expires_at 字符串(本地时区,与序列化端一致) // Logan.Li
|
||||
func parseClientRespTime(v string) time.Time {
|
||||
if v == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
t, err := time.ParseInLocation(clientRespTimeLayout, v, time.Local)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// signVerifyResponse 为 DTO 形式的验证响应补充 server_time、expires_at_unix 与 sig 字段 // Logan.Li
|
||||
func (s *LicenseService) signVerifyResponse(resp *dto.LicenseVerifyResponse) {
|
||||
if resp == nil {
|
||||
return
|
||||
}
|
||||
var expiresUnix int64
|
||||
if t := parseClientRespTime(resp.ExpiresAt); !t.IsZero() {
|
||||
expiresUnix = t.Unix()
|
||||
resp.ExpiresAtUnix = expiresUnix
|
||||
}
|
||||
resp.ServerTime, resp.Sig = s.signClientResponse(resp.LicenseKey, resp.Valid, resp.Status,
|
||||
expiresUnix, resp.InGrace, resp.MaxDevices, resp.PlanType)
|
||||
}
|
||||
|
||||
// signPayloadMap 为 map 形式的客户端响应(激活/心跳)补充 server_time、expires_at_unix 与 sig 字段 // Logan.Li
|
||||
func (s *LicenseService) signPayloadMap(m map[string]interface{}, licenseKey string) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
valid, _ := m["valid"].(bool)
|
||||
status, _ := m["status"].(string)
|
||||
expiresStr, _ := m["expires_at"].(string)
|
||||
maxDevices, _ := m["max_devices"].(int)
|
||||
inGrace, _ := m["in_grace"].(bool)
|
||||
planType, _ := m["plan_type"].(string)
|
||||
var expiresUnix int64
|
||||
if t := parseClientRespTime(expiresStr); !t.IsZero() {
|
||||
expiresUnix = t.Unix()
|
||||
m["expires_at_unix"] = expiresUnix
|
||||
}
|
||||
m["server_time"], m["sig"] = s.signClientResponse(licenseKey, valid, status,
|
||||
expiresUnix, inGrace, maxDevices, planType)
|
||||
}
|
||||
|
||||
// generateActivationToken 生成 32 字节随机设备激活令牌(hex) // Logan.Li
|
||||
func generateActivationToken() string {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
// 随机源失败属致命异常,直接 panic 比下发可预测令牌更安全 // Logan.Li
|
||||
log.Fatal("[安全] 生成激活令牌失败:", err)
|
||||
}
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
// checkDailyActivationLimit 校验单序列号 24 小时内新增设备数是否超限(防换座轮换共享)。
|
||||
// 统计口径为 activated_at(仅新建设备行会刷新),同设备重激活不计数。 // Logan.Li
|
||||
func (s *LicenseService) checkDailyActivationLimit(tx *gorm.DB, licenseID uint) error {
|
||||
limit := s.guard.activateDailyLimit
|
||||
if limit <= 0 {
|
||||
return nil
|
||||
}
|
||||
var count int64
|
||||
if err := tx.Model(&model.Activation{}).
|
||||
Where("license_id = ? AND activated_at > ?", licenseID, time.Now().Add(-24*time.Hour)).
|
||||
Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count >= int64(limit) {
|
||||
return errDailyActivationLimit
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// attachAbuseStats 基于预加载的激活记录计算疑似共享信号(不重复 IP 数、7 天新增设备数)。
|
||||
// 判定规则:不重复 IP 超过席位数+2,或 7 天新增设备超过 6 台。 // Logan.Li
|
||||
func attachAbuseStats(resp *dto.LicenseResponse, lic model.License) {
|
||||
if resp == nil {
|
||||
return
|
||||
}
|
||||
ips := make(map[string]struct{})
|
||||
weekAgo := time.Now().AddDate(0, 0, -7)
|
||||
newDevices := 0
|
||||
for _, a := range lic.Activations {
|
||||
if a.IP != "" {
|
||||
ips[a.IP] = struct{}{}
|
||||
}
|
||||
if a.ActivatedAt.After(weekAgo) {
|
||||
newDevices++
|
||||
}
|
||||
}
|
||||
resp.DistinctIPs = len(ips)
|
||||
resp.NewDevices7d = newDevices
|
||||
resp.AbuseSuspected = len(ips) > lic.MaxActivations+2 || newDevices > 6
|
||||
}
|
||||
|
||||
// StartOnlineSweeper 启动在线状态清扫任务:last_seen_at 超过 TTL 的激活记录置为离线,
|
||||
// 保证后台“在线数量”反映真实活跃而非历史峰值。每 10 分钟执行一轮。 // Logan.Li
|
||||
func StartOnlineSweeper(db *gorm.DB, ttlMinutes int) {
|
||||
if db == nil || ttlMinutes <= 0 {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ticker := time.NewTicker(10 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
sweep := func() {
|
||||
cutoff := time.Now().Add(-time.Duration(ttlMinutes) * time.Minute)
|
||||
res := db.Model(&model.Activation{}).
|
||||
Where("is_online = ? AND last_seen_at < ?", true, cutoff).
|
||||
Updates(map[string]interface{}{"is_online": false})
|
||||
if res.Error != nil {
|
||||
log.Printf("[清扫] 在线状态更新失败: %v", res.Error)
|
||||
} else if res.RowsAffected > 0 {
|
||||
log.Printf("[清扫] 已将 %d 条超时激活记录置为离线", res.RowsAffected)
|
||||
}
|
||||
}
|
||||
sweep() // 启动即执行一轮,纠正重启期间的历史状态 // Logan.Li
|
||||
for range ticker.C {
|
||||
sweep()
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
package license
|
||||
|
||||
import (
|
||||
"backend/config"
|
||||
"backend/internal/dto"
|
||||
"backend/internal/model"
|
||||
customersvc "backend/internal/service/customer"
|
||||
"backend/utils"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -16,12 +18,32 @@ import (
|
||||
|
||||
// LicenseService License 业务逻辑层 // Logan.Li
|
||||
type LicenseService struct {
|
||||
db *gorm.DB
|
||||
db *gorm.DB
|
||||
cfg *config.Config
|
||||
guard *clientGuard
|
||||
}
|
||||
|
||||
// NewLicenseService 创建 service 实例 // Logan.Li
|
||||
func NewLicenseService(db *gorm.DB) *LicenseService {
|
||||
return &LicenseService{db: db}
|
||||
// NewLicenseService 创建 service 实例(cfg 提供防护配置与响应签名密钥) // Logan.Li
|
||||
func NewLicenseService(db *gorm.DB, cfg *config.Config) *LicenseService {
|
||||
svc := &LicenseService{db: db, cfg: cfg}
|
||||
if cfg != nil {
|
||||
g := &clientGuard{
|
||||
gracePeriodDays: cfg.GracePeriodDays,
|
||||
activateDailyLimit: cfg.ActivateDailyLimit,
|
||||
onlineTTLMinutes: cfg.OnlineTTLMinutes,
|
||||
deactivateRequireToken: cfg.DeactivateRequireToken,
|
||||
}
|
||||
if seed := strings.TrimSpace(cfg.OfflineLicenseSeed); seed != "" {
|
||||
if kp, err := utils.DeriveOfflineKeyPair(seed); err == nil {
|
||||
g.kp = kp
|
||||
} else {
|
||||
// seed 非法只降级签名能力,不阻断启动(离线签发侧会再次报错) // Logan.Li
|
||||
log.Printf("[安全] 离线 seed 派生失败,响应签名将不可用: %v", err)
|
||||
}
|
||||
}
|
||||
svc.guard = g
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// ListQuery 列表查询参数 // Logan.Li
|
||||
@@ -67,7 +89,9 @@ func (s *LicenseService) List(page, pageSize int, q ListQuery) ([]dto.LicenseRes
|
||||
|
||||
results := make([]dto.LicenseResponse, 0, len(licenses))
|
||||
for _, l := range licenses {
|
||||
results = append(results, toLicenseResponse(l))
|
||||
resp := toLicenseResponse(l)
|
||||
attachAbuseStats(&resp, l)
|
||||
results = append(results, resp)
|
||||
}
|
||||
return results, total, nil
|
||||
}
|
||||
@@ -81,6 +105,7 @@ func (s *LicenseService) GetByID(id uint) (*dto.LicenseResponse, []dto.Activatio
|
||||
}
|
||||
|
||||
resp := toLicenseResponse(lic)
|
||||
attachAbuseStats(&resp, lic)
|
||||
activations := make([]dto.ActivationResponse, 0, len(lic.Activations))
|
||||
for _, a := range lic.Activations {
|
||||
activations = append(activations, toActivationResponse(a))
|
||||
@@ -180,9 +205,9 @@ func (s *LicenseService) Delete(id uint) (*model.License, error) {
|
||||
|
||||
// BatchDeleteResult 批量删除结果 // Logan.Li
|
||||
type BatchDeleteResult struct {
|
||||
Deleted int `json:"deleted"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
Deleted int `json:"deleted"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
SkippedIDs []uint `json:"skipped_ids,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"backend/config"
|
||||
"backend/database"
|
||||
licenseservice "backend/internal/service/license"
|
||||
"backend/router"
|
||||
"context"
|
||||
"log"
|
||||
@@ -23,6 +24,9 @@ func main() {
|
||||
log.Fatal("数据库初始化失败:", err)
|
||||
}
|
||||
|
||||
// 启动在线状态清扫(超时未心跳的激活记录置为离线) // Logan.Li
|
||||
licenseservice.StartOnlineSweeper(db, cfg.OnlineTTLMinutes)
|
||||
|
||||
// 初始化路由 // Logan.Li
|
||||
r := router.SetupRouter(db, cfg)
|
||||
|
||||
|
||||
@@ -56,6 +56,26 @@ func (k *OfflineKeyPair) OfflinePublicKeyHex() string {
|
||||
return hex.EncodeToString(k.Public)
|
||||
}
|
||||
|
||||
// SignHexString 对任意字符串签名,返回 hex 编码的 Ed25519 签名(用于在线验证响应防篡改) // Logan.Li
|
||||
func (k *OfflineKeyPair) SignHexString(msg string) string {
|
||||
if k == nil || len(k.Private) == 0 {
|
||||
return ""
|
||||
}
|
||||
return hex.EncodeToString(ed25519.Sign(k.Private, []byte(msg)))
|
||||
}
|
||||
|
||||
// VerifyHexString 校验 SignHexString 产生的 hex 签名(供测试与桌面端对照实现) // Logan.Li
|
||||
func (k *OfflineKeyPair) VerifyHexString(msg, sigHex string) bool {
|
||||
if k == nil || len(k.Public) == 0 {
|
||||
return false
|
||||
}
|
||||
sig, err := hex.DecodeString(strings.TrimSpace(sigHex))
|
||||
if err != nil || len(sig) != ed25519.SignatureSize {
|
||||
return false
|
||||
}
|
||||
return ed25519.Verify(k.Public, []byte(msg), sig)
|
||||
}
|
||||
|
||||
// IsOfflineLicenseKey 判断是否为离线序列号 // Logan.Li
|
||||
func IsOfflineLicenseKey(key string) bool {
|
||||
return strings.HasPrefix(strings.TrimSpace(key), "LICOFF-")
|
||||
|
||||
Reference in new issue
Block a user