feat: Z34b restore-by-host + harden web nginx DNS race
Add HMAC restore-by-host for phone-less rebind; resolve gateway at request time and recreate web after stack up. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -25,7 +25,7 @@ type Claims struct {
|
||||
LocalDatabaseID string `json:"local_database_id,omitempty"`
|
||||
Exp int64 `json:"exp"`
|
||||
JTI string `json:"jti"`
|
||||
Scope string `json:"scope,omitempty"` // sync_bind
|
||||
Scope string `json:"scope,omitempty"` // sync_bind | sync_restore
|
||||
YuhengUserID string `json:"yuheng_user_id,omitempty"`
|
||||
}
|
||||
|
||||
@@ -35,6 +35,10 @@ type VerifyOpts struct {
|
||||
Audience string // default aijianzhan
|
||||
Now time.Time
|
||||
MaxSkew time.Duration // clock skew; default 30s
|
||||
// AllowEmptyPhone:Z34b restore-by-host;phone 可空(仍须 host_key+jti)。
|
||||
AllowEmptyPhone bool
|
||||
// AllowedScopes:空则仅允许 sync_bind(含空 scope);restore 可传 sync_bind+sync_restore。
|
||||
AllowedScopes []string
|
||||
}
|
||||
|
||||
// Sign 供联调/测试;生产由宇恒侧用同一 Secret 签发。
|
||||
@@ -43,9 +47,6 @@ func Sign(secret string, c Claims) (string, error) {
|
||||
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"
|
||||
}
|
||||
@@ -55,6 +56,11 @@ func Sign(secret string, c Claims) (string, error) {
|
||||
if c.Scope == "" {
|
||||
c.Scope = "sync_bind"
|
||||
}
|
||||
// sync_restore:允许无 phone(换机仅凭宇恒账号 ID);其余 scope 仍须 phone。
|
||||
needPhone := c.Scope != "sync_restore"
|
||||
if c.JTI == "" || c.HostKey == "" || c.Exp == 0 || (needPhone && c.Phone == "") {
|
||||
return "", fmt.Errorf("jti/host_key/exp required; phone required unless scope=sync_restore")
|
||||
}
|
||||
raw, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -105,7 +111,19 @@ func Verify(ticket string, opts VerifyOpts) (*Claims, error) {
|
||||
if c.Aud != aud {
|
||||
return nil, fmt.Errorf("ticket audience mismatch")
|
||||
}
|
||||
if c.Scope != "" && c.Scope != "sync_bind" {
|
||||
scope := strings.TrimSpace(c.Scope)
|
||||
allowed := opts.AllowedScopes
|
||||
if len(allowed) == 0 {
|
||||
allowed = []string{"", "sync_bind"}
|
||||
}
|
||||
scopeOK := false
|
||||
for _, a := range allowed {
|
||||
if scope == strings.TrimSpace(a) {
|
||||
scopeOK = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !scopeOK {
|
||||
return nil, fmt.Errorf("ticket scope not allowed")
|
||||
}
|
||||
now := opts.Now
|
||||
@@ -124,7 +142,10 @@ func Verify(ticket string, opts VerifyOpts) (*Claims, error) {
|
||||
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) == "" {
|
||||
if strings.TrimSpace(c.HostKey) == "" || strings.TrimSpace(c.JTI) == "" {
|
||||
return nil, fmt.Errorf("ticket missing host_key/jti")
|
||||
}
|
||||
if strings.TrimSpace(c.Phone) == "" && !opts.AllowEmptyPhone && scope != "sync_restore" {
|
||||
return nil, fmt.Errorf("ticket missing phone/host_key/jti")
|
||||
}
|
||||
return &c, nil
|
||||
|
||||
@@ -35,6 +35,43 @@ func TestVerifyBadSig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignVerifyRestoreNoPhone(t *testing.T) {
|
||||
secret := "test-secret"
|
||||
now := time.Unix(1_700_000_000, 0).UTC()
|
||||
c := Claims{
|
||||
Iss: "yuheng", Aud: "aijianzhan", HostKey: "6655aabbccddeeff00112233",
|
||||
Exp: now.Add(90 * time.Second).Unix(), JTI: "jti-restore", Scope: "sync_restore",
|
||||
YuhengUserID: "6655aabbccddeeff00112233",
|
||||
}
|
||||
tok, err := Sign(secret, c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := Verify(tok, VerifyOpts{
|
||||
Secret: secret, Now: now, AllowEmptyPhone: true,
|
||||
AllowedScopes: []string{"sync_bind", "sync_restore"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.HostKey != c.HostKey || got.Phone != "" {
|
||||
t.Fatalf("claims mismatch: %+v", got)
|
||||
}
|
||||
// 默认 Verify(须 phone)应拒绝无 phone 的 sync_bind;sync_restore 在默认 scopes 外也应拒绝
|
||||
if _, err := Verify(tok, VerifyOpts{Secret: secret, Now: now}); err == nil {
|
||||
t.Fatal("expected scope reject without AllowedScopes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignRequiresPhoneForSyncBind(t *testing.T) {
|
||||
_, err := Sign("s", Claims{
|
||||
HostKey: "h", Exp: time.Now().Add(time.Minute).Unix(), JTI: "j", Scope: "sync_bind",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected phone required for sync_bind")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJTIConsume(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, err := NewJTIStore(dir)
|
||||
|
||||
Reference in New Issue
Block a user