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

+11
View File
@@ -20,6 +20,17 @@ JWT_SECRET=your-super-secret-key-change-this-in-production
# 当前生产公钥(桌面已嵌入): 108375d485c58a1a738f93c2cbb838f76331de981ee5da13d90a1f280672131d
# OFFLINE_LICENSE_SEED=
# 客户端授权防护
# 过期宽限天数(仅商业过期;吊销/暂停无宽限,立即生效)
GRACE_PERIOD_DAYS=15
# 单序列号 24 小时内新设备激活上限(0 = 不限制),抑制换座轮换共享
ACTIVATE_DAILY_LIMIT=5
# 激活记录在线状态有效期(分钟)。默认 8 天,适配客户端周检节奏;
# 如客户端心跳更频繁可调小(如 30 = 在线即 30 分钟内活跃)
ONLINE_TTL_MINUTES=11520
# 解绑是否强制校验设备激活令牌(新激活的设备必须持令牌才能解绑,防偷座)
DEACTIVATE_REQUIRE_TOKEN=true
# 数据库配置
# 可选: sqlite / mysql / postgres
DB_DRIVER=sqlite
+46 -11
View File
@@ -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,
+24 -16
View File
@@ -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"`
}
+2
View File
@@ -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
+196
View File
@@ -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"`
}
+4
View File
@@ -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)
+20
View File
@@ -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-")
+12
View File
@@ -50,6 +50,18 @@
<el-table-column label="激活" width="80">
<template #default="{ row }">{{ row.current_activations }}/{{ row.max_activations }}</template>
</el-table-column>
<el-table-column label="共享" width="90">
<template #default="{ row }">
<el-tooltip
v-if="row.abuse_suspected"
:content="`不重复 IP ${row.distinct_ips} 个,近 7 天新增设备 ${row.new_devices_7d} 台`"
placement="top"
>
<el-tag type="danger" size="small">疑似共享</el-tag>
</el-tooltip>
<span v-else style="color: var(--el-text-color-placeholder)">—</span>
</template>
</el-table-column>
<el-table-column label="操作" width="320" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="viewDetail(row)">详情</el-button>