3590df28db
New platform/telegram connector (own container, bot token only there): - go-telegram/bot long-poll loop: /start deep-links + Mini App launch button. - gRPC API pkg/proto/telegram/v1 (Telegram service): ValidateInitData, Notify (renders a localized message + deep-link button), SendToUser/SendToGameChannel (admin, wired in Stage 10). Generic methods are platform-agnostic (external_id). - Bot API base override for Telegram's test environment; Dockerfile + compose (VPN sidecar, no public ingress); README. Gateway: - initData validation relocated from the gateway into the connector; the gateway calls ValidateInitData over gRPC (GATEWAY_CONNECTOR_ADDR), drops the bot token, and deletes internal/auth. - Out-of-app push: runPushPump routes events whose recipient has no live in-app stream to connector.Notify, gated by /internal/push-target + the in-app-only flag (race-free de-dup); HasSubscribers added to the push hub. Backend: - Migration 00007 accounts.notifications_in_app_only (default true) + jetgen. - ProvisionTelegram seeds a new account's language/display name from the launch fields; IdentityExternalID reverse lookup; /internal/push-target handler. UI: - Telegram Mini App launch: detect initData, apply themeParams, authTelegram, route the deep-link start_param (g/i/f); /telegram/ guard redirects outside Telegram. Vite relative base + telegram-web-app.js. In-app-only profile toggle; share-to-Telegram link for a friend code. Vitest + Playwright coverage. Wire/docs/CI: fbs Profile/UpdateProfileRequest gain notifications_in_app_only (Go + TS); go.work uses ./platform/telegram; go-unit.yaml covers it; PLAN, ARCHITECTURE, FUNCTIONAL (+ru), UI_DESIGN, READMEs updated.
144 lines
4.2 KiB
Go
144 lines
4.2 KiB
Go
// Package initdata validates Telegram Mini App launch data (initData). It lives in
|
|
// the connector because the HMAC secret is the bot token, which is held only here
|
|
// (ARCHITECTURE.md §12); the gateway calls the connector's ValidateInitData RPC
|
|
// instead of validating the launch data itself.
|
|
package initdata
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ErrInvalidInitData is returned when initData fails HMAC validation, is missing
|
|
// the hash, is malformed, or is older than the freshness window.
|
|
var ErrInvalidInitData = errors.New("initdata: invalid telegram init data")
|
|
|
|
// defaultMaxAge bounds how old a validated initData payload may be.
|
|
const defaultMaxAge = 24 * time.Hour
|
|
|
|
// User is the identity extracted from a validated initData payload. ExternalID is
|
|
// the Telegram user id used as the identities external_id; LanguageCode seeds a
|
|
// new account's preferred language (Stage 9).
|
|
type User struct {
|
|
ExternalID string
|
|
Username string
|
|
FirstName string
|
|
LanguageCode string
|
|
}
|
|
|
|
// Validator validates Telegram Web App launch data and returns the authenticated
|
|
// user. It is an interface so the connector can be tested with a fixture.
|
|
type Validator interface {
|
|
Validate(initData string) (User, error)
|
|
}
|
|
|
|
// HMACValidator validates initData against a bot token per Telegram's documented
|
|
// algorithm: the data-check string is HMAC-SHA256'd under a secret derived from
|
|
// the bot token, and the result is compared with the supplied hash.
|
|
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 initData, returning the authenticated user.
|
|
func (v *HMACValidator) Validate(initData string) (User, error) {
|
|
values, err := url.ParseQuery(initData)
|
|
if err != nil {
|
|
return User{}, ErrInvalidInitData
|
|
}
|
|
hash := values.Get("hash")
|
|
if hash == "" {
|
|
return User{}, ErrInvalidInitData
|
|
}
|
|
values.Del("hash")
|
|
|
|
if !v.checkSignature(values, hash) {
|
|
return User{}, ErrInvalidInitData
|
|
}
|
|
if err := v.checkFreshness(values.Get("auth_date")); err != nil {
|
|
return User{}, err
|
|
}
|
|
return parseUser(values.Get("user"))
|
|
}
|
|
|
|
// checkSignature recomputes the HMAC over the sorted data-check string 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 := hmacSHA256([]byte("WebAppData"), []byte(v.botToken))
|
|
want := hmacSHA256(secret, []byte(dataCheck))
|
|
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 ErrInvalidInitData
|
|
}
|
|
secs, err := strconv.ParseInt(authDate, 10, 64)
|
|
if err != nil {
|
|
return ErrInvalidInitData
|
|
}
|
|
if v.now().Sub(time.Unix(secs, 0)) > v.maxAge {
|
|
return ErrInvalidInitData
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// parseUser extracts the user id, names and language from the user JSON field.
|
|
func parseUser(userJSON string) (User, error) {
|
|
if userJSON == "" {
|
|
return User{}, ErrInvalidInitData
|
|
}
|
|
var u struct {
|
|
ID int64 `json:"id"`
|
|
Username string `json:"username"`
|
|
FirstName string `json:"first_name"`
|
|
LanguageCode string `json:"language_code"`
|
|
}
|
|
if err := json.Unmarshal([]byte(userJSON), &u); err != nil || u.ID == 0 {
|
|
return User{}, ErrInvalidInitData
|
|
}
|
|
return User{
|
|
ExternalID: strconv.FormatInt(u.ID, 10),
|
|
Username: u.Username,
|
|
FirstName: u.FirstName,
|
|
LanguageCode: u.LanguageCode,
|
|
}, nil
|
|
}
|
|
|
|
// hmacSHA256 returns HMAC-SHA256(message) under key.
|
|
func hmacSHA256(key, message []byte) []byte {
|
|
h := hmac.New(sha256.New, key)
|
|
h.Write(message)
|
|
return h.Sum(nil)
|
|
}
|