Stage 11: account linking & merge (email + Telegram Login Widget)
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 11s
Tests · UI / test (push) Successful in 20s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 11s
Tests · UI / test (pull_request) Successful in 19s
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 11s
Tests · UI / test (push) Successful in 20s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 11s
Tests · UI / test (pull_request) Successful in 19s
Link an email (confirm-code) or Telegram (web Login Widget) to the current account; if the identity already has its own account, merge the two into the one in use (the current account is primary, except a guest initiator whose durable counterpart wins). The merge runs in one transaction (internal/accountmerge): stats + hint wallet summed, paid_account ORed, identities/games/chat/complaints transferred, friends/blocks de-duplicated, the secondary kept as a merged_into tombstone so a shared finished game's no-cascade FKs hold; a shared active game blocks the merge. - migration 00009: accounts.paid_account, merged_into, merged_at (+ jetgen) - internal/link orchestrator; session.RevokeAllForAccount on merge - connector ValidateLoginWidget RPC + loginwidget HMAC validator - edge ops link.email.request/confirm/merge, link.telegram.confirm/merge; supersedes the Stage 8 email.bind.* surface (request never reveals 'taken' before the code is verified, so a probe cannot enumerate addresses) - UI Profile link section + irreversible-merge dialog; Telegram web sign-in - focused regression tests (merge core, guest inversion, active-game refusal, finished-shared-game kept), gateway transcode + connector + UI codec/e2e - docs: PLAN, ARCHITECTURE 3/4/9, FUNCTIONAL(+ru), module READMEs
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
package loginwidget
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testToken = "123456:TESTTOKEN"
|
||||
|
||||
// signWidget builds validly signed Login Widget data for the token and fields,
|
||||
// mirroring Telegram's algorithm (secret = SHA-256(token); HMAC over the sorted
|
||||
// data-check string).
|
||||
func signWidget(token string, fields map[string]string) string {
|
||||
keys := make([]string, 0, len(fields))
|
||||
for k := range fields {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
lines := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
lines = append(lines, k+"="+fields[k])
|
||||
}
|
||||
secret := sha256.Sum256([]byte(token))
|
||||
mac := hmac.New(sha256.New, secret[:])
|
||||
mac.Write([]byte(strings.Join(lines, "\n")))
|
||||
|
||||
v := url.Values{}
|
||||
for k, val := range fields {
|
||||
v.Set(k, val)
|
||||
}
|
||||
v.Set("hash", hex.EncodeToString(mac.Sum(nil)))
|
||||
return v.Encode()
|
||||
}
|
||||
|
||||
func freshFields() map[string]string {
|
||||
return map[string]string{
|
||||
"auth_date": strconv.FormatInt(time.Now().Unix(), 10),
|
||||
"id": "42",
|
||||
"username": "neo",
|
||||
"first_name": "Thomas",
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOK(t *testing.T) {
|
||||
data := signWidget(testToken, freshFields())
|
||||
u, err := NewHMACValidator(testToken).Validate(data)
|
||||
if err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
if u.ExternalID != "42" || u.Username != "neo" || u.FirstName != "Thomas" {
|
||||
t.Errorf("user = %+v, want {42 neo Thomas}", u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejects(t *testing.T) {
|
||||
valid := signWidget(testToken, freshFields())
|
||||
|
||||
t.Run("tampered hash", func(t *testing.T) {
|
||||
tampered := strings.Replace(valid, "hash=", "hash=00", 1)
|
||||
if _, err := NewHMACValidator(testToken).Validate(tampered); !errors.Is(err, ErrInvalidLoginWidget) {
|
||||
t.Errorf("err = %v, want ErrInvalidLoginWidget", err)
|
||||
}
|
||||
})
|
||||
t.Run("wrong token", func(t *testing.T) {
|
||||
if _, err := NewHMACValidator("other:TOKEN").Validate(valid); !errors.Is(err, ErrInvalidLoginWidget) {
|
||||
t.Errorf("err = %v, want ErrInvalidLoginWidget", err)
|
||||
}
|
||||
})
|
||||
t.Run("tampered field", func(t *testing.T) {
|
||||
// Flip the id after signing: the HMAC must no longer match.
|
||||
forged := strings.Replace(valid, "id=42", "id=43", 1)
|
||||
if _, err := NewHMACValidator(testToken).Validate(forged); !errors.Is(err, ErrInvalidLoginWidget) {
|
||||
t.Errorf("err = %v, want ErrInvalidLoginWidget", err)
|
||||
}
|
||||
})
|
||||
t.Run("missing hash", func(t *testing.T) {
|
||||
if _, err := NewHMACValidator(testToken).Validate("id=42&auth_date=1"); !errors.Is(err, ErrInvalidLoginWidget) {
|
||||
t.Errorf("err = %v, want ErrInvalidLoginWidget", err)
|
||||
}
|
||||
})
|
||||
t.Run("stale auth_date", func(t *testing.T) {
|
||||
stale := signWidget(testToken, map[string]string{
|
||||
"auth_date": strconv.FormatInt(time.Now().Add(-48*time.Hour).Unix(), 10),
|
||||
"id": "42",
|
||||
})
|
||||
if _, err := NewHMACValidator(testToken).Validate(stale); !errors.Is(err, ErrInvalidLoginWidget) {
|
||||
t.Errorf("err = %v, want ErrInvalidLoginWidget", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user