feat: 客户端授权防护——响应签名/激活令牌/宽限期/换座限制/在线清扫/疑似共享标记
This commit is contained in:
1 parent
37d9e8ca56
commit
b76111dfc3
11 files changed
+437
-54
No files matched your search
@@ -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()
|
||||
}
|
||||
}()
|
||||
}
|
||||
Reference in new issue
Block a user