Files
ai_site/platform/internal/yuhticket/ticket.go
whm d195aa4804 feat: add Yuheng ticket bind, trial SMS off, shared bindings
Ship ticket-exchange and bind/policy for Z13, keep trial binds SMS-free, allow shared company bindings, and align SyncPage plus sync docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 15:09:49 +08:00

188 lines
4.7 KiB
Go
Raw 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 yuhticket
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const Prefix = "YHT1"
// Claims 宇恒签发的短时凭票(仅用于智建 sync 绑定/免登录换票)。
type Claims struct {
Iss string `json:"iss"`
Aud string `json:"aud"`
Phone string `json:"phone"`
HostKey string `json:"host_key"`
Name string `json:"name,omitempty"`
LocalDatabaseID string `json:"local_database_id,omitempty"`
Exp int64 `json:"exp"`
JTI string `json:"jti"`
Scope string `json:"scope,omitempty"` // sync_bind
YuhengUserID string `json:"yuheng_user_id,omitempty"`
}
type VerifyOpts struct {
Secret string
Issuer string // default yuheng
Audience string // default aijianzhan
Now time.Time
MaxSkew time.Duration // clock skew; default 30s
}
// Sign 供联调/测试;生产由宇恒侧用同一 Secret 签发。
func Sign(secret string, c Claims) (string, error) {
secret = strings.TrimSpace(secret)
if secret == "" {
return "", fmt.Errorf("ticket secret empty")
}
if c.JTI == "" || c.Phone == "" || c.HostKey == "" || c.Exp == 0 {
return "", fmt.Errorf("jti/phone/host_key/exp required")
}
if c.Iss == "" {
c.Iss = "yuheng"
}
if c.Aud == "" {
c.Aud = "aijianzhan"
}
if c.Scope == "" {
c.Scope = "sync_bind"
}
raw, err := json.Marshal(c)
if err != nil {
return "", err
}
payload := base64.RawURLEncoding.EncodeToString(raw)
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(Prefix + "." + payload))
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return Prefix + "." + payload + "." + sig, nil
}
func Verify(ticket string, opts VerifyOpts) (*Claims, error) {
ticket = strings.TrimSpace(ticket)
secret := strings.TrimSpace(opts.Secret)
if secret == "" {
return nil, fmt.Errorf("宇恒凭票未配置Agent.YuhengTicket.Secret")
}
parts := strings.Split(ticket, ".")
if len(parts) != 3 || parts[0] != Prefix {
return nil, fmt.Errorf("invalid ticket format")
}
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(parts[0] + "." + parts[1]))
want := mac.Sum(nil)
got, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil || !hmac.Equal(want, got) {
return nil, fmt.Errorf("ticket signature invalid")
}
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("ticket payload invalid")
}
var c Claims
if err := json.Unmarshal(raw, &c); err != nil {
return nil, fmt.Errorf("ticket claims invalid")
}
iss := strings.TrimSpace(opts.Issuer)
if iss == "" {
iss = "yuheng"
}
aud := strings.TrimSpace(opts.Audience)
if aud == "" {
aud = "aijianzhan"
}
if c.Iss != iss {
return nil, fmt.Errorf("ticket issuer not allowed")
}
if c.Aud != aud {
return nil, fmt.Errorf("ticket audience mismatch")
}
if c.Scope != "" && c.Scope != "sync_bind" {
return nil, fmt.Errorf("ticket scope not allowed")
}
now := opts.Now
if now.IsZero() {
now = time.Now().UTC()
}
skew := opts.MaxSkew
if skew <= 0 {
skew = 30 * time.Second
}
exp := time.Unix(c.Exp, 0).UTC()
if now.After(exp.Add(skew)) {
return nil, fmt.Errorf("ticket expired")
}
// 拒绝过远未来的 exp防永久票
if exp.After(now.Add(10 * time.Minute)) {
return nil, fmt.Errorf("ticket exp too far")
}
if strings.TrimSpace(c.Phone) == "" || strings.TrimSpace(c.HostKey) == "" || strings.TrimSpace(c.JTI) == "" {
return nil, fmt.Errorf("ticket missing phone/host_key/jti")
}
return &c, nil
}
// JTIStore 防重放(短 TTL
type JTIStore struct {
mu sync.Mutex
path string
seen map[string]int64 // jti -> exp unix
}
func NewJTIStore(dir string) (*JTIStore, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
s := &JTIStore{path: filepath.Join(dir, "yuheng_ticket_jti.json"), seen: map[string]int64{}}
_ = s.load()
return s, nil
}
func (s *JTIStore) load() error {
b, err := os.ReadFile(s.path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
return json.Unmarshal(b, &s.seen)
}
func (s *JTIStore) save() error {
b, err := json.Marshal(s.seen)
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
// Consume 若 jti 已用则报错;否则记入至 exp。
func (s *JTIStore) Consume(jti string, expUnix int64) error {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now().Unix()
for k, exp := range s.seen {
if exp < now {
delete(s.seen, k)
}
}
if _, ok := s.seen[jti]; ok {
return fmt.Errorf("ticket already used")
}
s.seen[jti] = expUnix
return s.save()
}