Files

197 lines
6.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}
}()
}