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

+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()
}
}()
}