diff --git a/.env.example b/.env.example index bdf082b..21e93e8 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/backend/config/config.go b/backend/config/config.go index c310841..d59638a 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -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 +} diff --git a/backend/internal/controller/license/license_controller.go b/backend/internal/controller/license/license_controller.go index dd1c7cf..ae4781a 100644 --- a/backend/internal/controller/license/license_controller.go +++ b/backend/internal/controller/license/license_controller.go @@ -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, diff --git a/backend/internal/dto/license.go b/backend/internal/dto/license.go index 9859e4a..52040a5 100644 --- a/backend/internal/dto/license.go +++ b/backend/internal/dto/license.go @@ -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"` } - diff --git a/backend/internal/model/license.go b/backend/internal/model/license.go index 462816f..5597ab7 100644 --- a/backend/internal/model/license.go +++ b/backend/internal/model/license.go @@ -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"` diff --git a/backend/internal/service/license/client_service.go b/backend/internal/service/license/client_service.go index 3dee2e7..74e51be 100644 --- a/backend/internal/service/license/client_service.go +++ b/backend/internal/service/license/client_service.go @@ -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 diff --git a/backend/internal/service/license/guard.go b/backend/internal/service/license/guard.go new file mode 100644 index 0000000..c2f8890 --- /dev/null +++ b/backend/internal/service/license/guard.go @@ -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() + } + }() +} diff --git a/backend/internal/service/license/license_service.go b/backend/internal/service/license/license_service.go index 90e96de..d3e5be8 100644 --- a/backend/internal/service/license/license_service.go +++ b/backend/internal/service/license/license_service.go @@ -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"` } diff --git a/backend/main.go b/backend/main.go index 625554d..8d4e059 100644 --- a/backend/main.go +++ b/backend/main.go @@ -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) diff --git a/backend/utils/offline_license.go b/backend/utils/offline_license.go index 4489841..963bb59 100644 --- a/backend/utils/offline_license.go +++ b/backend/utils/offline_license.go @@ -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-") diff --git a/frontend/src/views/Licenses.vue b/frontend/src/views/Licenses.vue index 2a73195..61cc003 100644 --- a/frontend/src/views/Licenses.vue +++ b/frontend/src/views/Licenses.vue @@ -50,6 +50,18 @@ + + +