feat: 通用授权后台、离线证、Waffo 支付页与仪表盘加强
支持多产品计划展示、LICOFF 离线签发、支付独立配置与调试,以及分析看板;同步更新 README 与集成文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
0966375076
commit
8cc2c83f9d
41 files changed
+3177
-332
No files matched your search
@@ -0,0 +1,176 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// OfflineLicensePrefix 离线序列号前缀(与在线 LIC- 区分) // Logan.Li
|
||||
OfflineLicensePrefix = "LICOFF-1."
|
||||
// OfflineMaxDurationDays 离线证最长有效期(禁止永久离线证) // Logan.Li
|
||||
OfflineMaxDurationDays = 365
|
||||
)
|
||||
|
||||
// OfflineLicenseClaims 离线许可证声明(自包含,桌面本地验签) // Logan.Li
|
||||
type OfflineLicenseClaims struct {
|
||||
V int `json:"v"`
|
||||
ProductCode string `json:"pid"`
|
||||
DeviceID string `json:"did"`
|
||||
Email string `json:"email,omitempty"`
|
||||
PlanType string `json:"pt"`
|
||||
IssuedAt int64 `json:"iat"`
|
||||
ExpiresAt int64 `json:"exp"`
|
||||
MaxDevices int `json:"md"`
|
||||
CustomerName string `json:"cn,omitempty"`
|
||||
}
|
||||
|
||||
// OfflineKeyPair 离线签发密钥对 // Logan.Li
|
||||
type OfflineKeyPair struct {
|
||||
Private ed25519.PrivateKey
|
||||
Public ed25519.PublicKey
|
||||
}
|
||||
|
||||
// DeriveOfflineKeyPair 从 32 字节 seed(hex)派生 Ed25519 密钥对 // Logan.Li
|
||||
func DeriveOfflineKeyPair(seedHex string) (*OfflineKeyPair, error) {
|
||||
seedHex = strings.TrimSpace(seedHex)
|
||||
if seedHex == "" {
|
||||
return nil, errors.New("离线签发 seed 为空")
|
||||
}
|
||||
raw, err := hex.DecodeString(seedHex)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("离线签发 seed 不是合法 hex: %w", err)
|
||||
}
|
||||
if len(raw) != ed25519.SeedSize {
|
||||
// 允许任意长度输入,统一 hash 成 32 字节 // Logan.Li
|
||||
sum := sha256.Sum256(raw)
|
||||
raw = sum[:]
|
||||
}
|
||||
priv := ed25519.NewKeyFromSeed(raw)
|
||||
pub := priv.Public().(ed25519.PublicKey)
|
||||
return &OfflineKeyPair{Private: priv, Public: pub}, nil
|
||||
}
|
||||
|
||||
// OfflinePublicKeyHex 返回公钥 hex(供桌面端嵌入) // Logan.Li
|
||||
func (k *OfflineKeyPair) OfflinePublicKeyHex() string {
|
||||
return hex.EncodeToString(k.Public)
|
||||
}
|
||||
|
||||
// IsOfflineLicenseKey 判断是否为离线序列号 // Logan.Li
|
||||
func IsOfflineLicenseKey(key string) bool {
|
||||
return strings.HasPrefix(strings.TrimSpace(key), "LICOFF-")
|
||||
}
|
||||
|
||||
// SignOfflineLicense 签发离线序列号:LICOFF-1.<payload_b64url>.<sig_b64url> // Logan.Li
|
||||
func SignOfflineLicense(kp *OfflineKeyPair, claims OfflineLicenseClaims) (string, error) {
|
||||
if kp == nil || len(kp.Private) == 0 {
|
||||
return "", errors.New("离线签发密钥未配置")
|
||||
}
|
||||
if err := validateOfflineClaims(claims, true); err != nil {
|
||||
return "", err
|
||||
}
|
||||
claims.V = 1
|
||||
payload, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
payloadB64 := base64.RawURLEncoding.EncodeToString(payload)
|
||||
sig := ed25519.Sign(kp.Private, []byte(payloadB64))
|
||||
sigB64 := base64.RawURLEncoding.EncodeToString(sig)
|
||||
return OfflineLicensePrefix + payloadB64 + "." + sigB64, nil
|
||||
}
|
||||
|
||||
// VerifyOfflineLicense 验签并解析离线序列号(不校验设备与过期,由调用方决定) // Logan.Li
|
||||
func VerifyOfflineLicense(publicKey ed25519.PublicKey, serial string) (*OfflineLicenseClaims, error) {
|
||||
serial = strings.TrimSpace(serial)
|
||||
if !strings.HasPrefix(serial, OfflineLicensePrefix) {
|
||||
return nil, errors.New("不是合法的离线序列号(需 LICOFF-1. 前缀)")
|
||||
}
|
||||
body := strings.TrimPrefix(serial, OfflineLicensePrefix)
|
||||
parts := strings.Split(body, ".")
|
||||
if len(parts) != 2 {
|
||||
return nil, errors.New("离线序列号格式错误")
|
||||
}
|
||||
payloadB64, sigB64 := parts[0], parts[1]
|
||||
sig, err := base64.RawURLEncoding.DecodeString(sigB64)
|
||||
if err != nil {
|
||||
return nil, errors.New("离线序列号签名无法解码")
|
||||
}
|
||||
if len(publicKey) != ed25519.PublicKeySize {
|
||||
return nil, errors.New("离线验签公钥无效")
|
||||
}
|
||||
if !ed25519.Verify(publicKey, []byte(payloadB64), sig) {
|
||||
return nil, errors.New("离线序列号签名无效")
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(payloadB64)
|
||||
if err != nil {
|
||||
return nil, errors.New("离线序列号载荷无法解码")
|
||||
}
|
||||
var claims OfflineLicenseClaims
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return nil, errors.New("离线序列号载荷无法解析")
|
||||
}
|
||||
if err := validateOfflineClaims(claims, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &claims, nil
|
||||
}
|
||||
|
||||
// ParseOfflinePublicKeyHex 解析公钥 hex // Logan.Li
|
||||
func ParseOfflinePublicKeyHex(hexKey string) (ed25519.PublicKey, error) {
|
||||
raw, err := hex.DecodeString(strings.TrimSpace(hexKey))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) != ed25519.PublicKeySize {
|
||||
return nil, errors.New("公钥长度错误")
|
||||
}
|
||||
return ed25519.PublicKey(raw), nil
|
||||
}
|
||||
|
||||
func validateOfflineClaims(c OfflineLicenseClaims, issuing bool) error {
|
||||
if strings.TrimSpace(c.DeviceID) == "" {
|
||||
return errors.New("device_id 不能为空")
|
||||
}
|
||||
pt := strings.TrimSpace(c.PlanType)
|
||||
if pt != "trial" && pt != "yearly" {
|
||||
return errors.New("离线证仅允许 trial / yearly,禁止永久")
|
||||
}
|
||||
if c.ExpiresAt <= 0 || c.IssuedAt <= 0 {
|
||||
return errors.New("签发/到期时间无效")
|
||||
}
|
||||
if c.ExpiresAt <= c.IssuedAt {
|
||||
return errors.New("到期时间必须晚于签发时间")
|
||||
}
|
||||
maxSpan := int64(OfflineMaxDurationDays) * 24 * 3600
|
||||
if c.ExpiresAt-c.IssuedAt > maxSpan+3600 { // 容许 1h 时钟
|
||||
return errors.New("离线证有效期不得超过 365 天")
|
||||
}
|
||||
if issuing {
|
||||
now := time.Now().Unix()
|
||||
if c.ExpiresAt <= now {
|
||||
return errors.New("到期时间已过")
|
||||
}
|
||||
if c.ExpiresAt-now > maxSpan+3600 {
|
||||
return errors.New("离线证有效期不得超过 365 天")
|
||||
}
|
||||
}
|
||||
if c.MaxDevices <= 0 {
|
||||
c.MaxDevices = 1
|
||||
}
|
||||
if c.MaxDevices > 1 {
|
||||
// 离线证绑定单设备,强制 1 // Logan.Li
|
||||
return errors.New("离线证仅支持 1 台设备")
|
||||
}
|
||||
if strings.TrimSpace(c.ProductCode) == "" {
|
||||
return errors.New("product_code 不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOfflineLicenseSignAndVerify(t *testing.T) {
|
||||
kp, err := DeriveOfflineKeyPair("319de107090c6fd0aae9b7db343376dd5a22ccfb24b631ee67144141b1d32000")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
claims := OfflineLicenseClaims{
|
||||
ProductCode: "opsxshell",
|
||||
DeviceID: "dev-1",
|
||||
Email: "a@b.com",
|
||||
PlanType: "yearly",
|
||||
IssuedAt: now,
|
||||
ExpiresAt: now + 86400*30,
|
||||
MaxDevices: 1,
|
||||
}
|
||||
serial, err := SignOfflineLicense(kp, claims)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !IsOfflineLicenseKey(serial) {
|
||||
t.Fatalf("prefix: %s", serial)
|
||||
}
|
||||
got, err := VerifyOfflineLicense(kp.Public, serial)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.DeviceID != "dev-1" || got.PlanType != "yearly" {
|
||||
t.Fatalf("claims mismatch: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfflineLicenseRejectsPerpetual(t *testing.T) {
|
||||
kp, err := DeriveOfflineKeyPair("319de107090c6fd0aae9b7db343376dd5a22ccfb24b631ee67144141b1d32000")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
_, err = SignOfflineLicense(kp, OfflineLicenseClaims{
|
||||
ProductCode: "opsxshell",
|
||||
DeviceID: "dev-1",
|
||||
PlanType: "perpetual",
|
||||
IssuedAt: now,
|
||||
ExpiresAt: now + 86400,
|
||||
MaxDevices: 1,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected perpetual reject")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfflineLicenseRejectsOver365(t *testing.T) {
|
||||
kp, err := DeriveOfflineKeyPair("319de107090c6fd0aae9b7db343376dd5a22ccfb24b631ee67144141b1d32000")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
_, err = SignOfflineLicense(kp, OfflineLicenseClaims{
|
||||
ProductCode: "opsxshell",
|
||||
DeviceID: "dev-1",
|
||||
PlanType: "yearly",
|
||||
IssuedAt: now,
|
||||
ExpiresAt: now + 86400*400,
|
||||
MaxDevices: 1,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected over-365 reject")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user