52f898ca6f
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
123 lines
3.8 KiB
Go
123 lines
3.8 KiB
Go
// Package loginwidget validates Telegram Login Widget authorization data, the
|
|
// web (non-Mini-App) sign-in flow used to attach a Telegram identity to an existing
|
|
// account during linking (Stage 11). Like initdata it lives in the connector
|
|
// because the secret is derived from the bot token, held only here
|
|
// (ARCHITECTURE.md §12); the gateway calls the connector's ValidateLoginWidget RPC.
|
|
//
|
|
// The Login Widget algorithm differs from Mini App initData: the secret key is
|
|
// SHA-256(bot_token) (not HMAC(bot_token, "WebAppData")), the data-check string is
|
|
// the sorted key=value lines of the top-level fields (id, first_name, username,
|
|
// auth_date, ...), and there is no nested user JSON or language_code.
|
|
package loginwidget
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ErrInvalidLoginWidget is returned when the data fails HMAC validation, is
|
|
// missing the hash or id, is malformed, or is older than the freshness window.
|
|
var ErrInvalidLoginWidget = errors.New("loginwidget: invalid telegram login widget data")
|
|
|
|
// defaultMaxAge bounds how old a validated payload may be.
|
|
const defaultMaxAge = 24 * time.Hour
|
|
|
|
// User is the identity extracted from validated Login Widget data. ExternalID is
|
|
// the Telegram user id used as the identities external_id.
|
|
type User struct {
|
|
ExternalID string
|
|
Username string
|
|
FirstName string
|
|
}
|
|
|
|
// Validator validates Login Widget data and returns the authenticated user. It is
|
|
// an interface so the connector can be tested with a fixture.
|
|
type Validator interface {
|
|
Validate(data string) (User, error)
|
|
}
|
|
|
|
// HMACValidator validates Login Widget data against a bot token.
|
|
type HMACValidator struct {
|
|
botToken string
|
|
maxAge time.Duration
|
|
now func() time.Time
|
|
}
|
|
|
|
// NewHMACValidator constructs a validator for botToken.
|
|
func NewHMACValidator(botToken string) *HMACValidator {
|
|
return &HMACValidator{botToken: botToken, maxAge: defaultMaxAge, now: time.Now}
|
|
}
|
|
|
|
// Validate parses and verifies the widget data (a URL-encoded key=value query
|
|
// string carrying the widget fields plus hash) and returns the authenticated user.
|
|
func (v *HMACValidator) Validate(data string) (User, error) {
|
|
values, err := url.ParseQuery(data)
|
|
if err != nil {
|
|
return User{}, ErrInvalidLoginWidget
|
|
}
|
|
hash := values.Get("hash")
|
|
if hash == "" {
|
|
return User{}, ErrInvalidLoginWidget
|
|
}
|
|
values.Del("hash")
|
|
|
|
if !v.checkSignature(values, hash) {
|
|
return User{}, ErrInvalidLoginWidget
|
|
}
|
|
if err := v.checkFreshness(values.Get("auth_date")); err != nil {
|
|
return User{}, err
|
|
}
|
|
id := values.Get("id")
|
|
if id == "" {
|
|
return User{}, ErrInvalidLoginWidget
|
|
}
|
|
return User{ExternalID: id, Username: values.Get("username"), FirstName: values.Get("first_name")}, nil
|
|
}
|
|
|
|
// checkSignature recomputes the HMAC over the sorted data-check string under the
|
|
// SHA-256(bot_token) secret and compares it with hash in constant time.
|
|
func (v *HMACValidator) checkSignature(values url.Values, hash string) bool {
|
|
keys := make([]string, 0, len(values))
|
|
for k := range values {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
lines := make([]string, 0, len(keys))
|
|
for _, k := range keys {
|
|
lines = append(lines, k+"="+values.Get(k))
|
|
}
|
|
dataCheck := strings.Join(lines, "\n")
|
|
|
|
secret := sha256.Sum256([]byte(v.botToken))
|
|
mac := hmac.New(sha256.New, secret[:])
|
|
mac.Write([]byte(dataCheck))
|
|
want := mac.Sum(nil)
|
|
got, err := hex.DecodeString(hash)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return hmac.Equal(want, got)
|
|
}
|
|
|
|
// checkFreshness rejects an auth_date older than the validator's window.
|
|
func (v *HMACValidator) checkFreshness(authDate string) error {
|
|
if authDate == "" {
|
|
return ErrInvalidLoginWidget
|
|
}
|
|
secs, err := strconv.ParseInt(authDate, 10, 64)
|
|
if err != nil {
|
|
return ErrInvalidLoginWidget
|
|
}
|
|
if v.now().Sub(time.Unix(secs, 0)) > v.maxAge {
|
|
return ErrInvalidLoginWidget
|
|
}
|
|
return nil
|
|
}
|