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>
This commit is contained in:
187
platform/internal/yuhticket/ticket.go
Normal file
187
platform/internal/yuhticket/ticket.go
Normal file
@@ -0,0 +1,187 @@
|
||||
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()
|
||||
}
|
||||
51
platform/internal/yuhticket/ticket_test.go
Normal file
51
platform/internal/yuhticket/ticket_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package yuhticket
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSignVerifyRoundTrip(t *testing.T) {
|
||||
secret := "test-secret"
|
||||
now := time.Unix(1_700_000_000, 0).UTC()
|
||||
c := Claims{
|
||||
Iss: "yuheng", Aud: "aijianzhan", Phone: "13531041944",
|
||||
HostKey: "host-1", Exp: now.Add(90 * time.Second).Unix(), JTI: "jti-ok", Scope: "sync_bind",
|
||||
}
|
||||
tok, err := Sign(secret, c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := Verify(tok, VerifyOpts{Secret: secret, Now: now})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Phone != c.Phone || got.HostKey != c.HostKey {
|
||||
t.Fatalf("claims mismatch: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBadSig(t *testing.T) {
|
||||
now := time.Unix(1_700_000_000, 0).UTC()
|
||||
tok, _ := Sign("a", Claims{
|
||||
Phone: "13800000001", HostKey: "h", Exp: now.Add(60 * time.Second).Unix(), JTI: "j1",
|
||||
})
|
||||
if _, err := Verify(tok, VerifyOpts{Secret: "b", Now: now}); err == nil {
|
||||
t.Fatal("expected bad sig")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJTIConsume(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, err := NewJTIStore(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exp := time.Now().Add(time.Minute).Unix()
|
||||
if err := st.Consume("x", exp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.Consume("x", exp); err == nil {
|
||||
t.Fatal("expected replay")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user