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:
@@ -30,8 +30,12 @@ Telegram-specific.
|
||||
|
||||
## gRPC API
|
||||
|
||||
`pkg/proto/telegram/v1`, service `Telegram`: `ValidateInitData`, `Notify`,
|
||||
`SendToUser`, `SendToGameChannel`. Generated Go is committed under `pkg`.
|
||||
`pkg/proto/telegram/v1`, service `Telegram`: `ValidateInitData`,
|
||||
`ValidateLoginWidget`, `Notify`, `SendToUser`, `SendToGameChannel`. Generated Go is
|
||||
committed under `pkg`. `ValidateLoginWidget` (Stage 11) verifies Telegram **Login
|
||||
Widget** web sign-in data — HMAC under `SHA-256(bot_token)`, distinct from initData
|
||||
(`internal/loginwidget`) — for attaching a Telegram identity to an account from a
|
||||
browser.
|
||||
|
||||
## Deep-link scheme
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"scrabble/platform/telegram/internal/config"
|
||||
"scrabble/platform/telegram/internal/connector"
|
||||
"scrabble/platform/telegram/internal/initdata"
|
||||
"scrabble/platform/telegram/internal/loginwidget"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -54,7 +55,10 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srv := connector.NewServer(initdata.NewHMACValidator(cfg.BotToken), b, cfg.GameChannelID, logger)
|
||||
srv := connector.NewServer(
|
||||
initdata.NewHMACValidator(cfg.BotToken),
|
||||
loginwidget.NewHMACValidator(cfg.BotToken),
|
||||
b, cfg.GameChannelID, logger)
|
||||
|
||||
grpcServer := grpc.NewServer()
|
||||
telegramv1.RegisterTelegramServer(grpcServer, srv)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
telegramv1 "scrabble/pkg/proto/telegram/v1"
|
||||
"scrabble/platform/telegram/internal/initdata"
|
||||
"scrabble/platform/telegram/internal/loginwidget"
|
||||
"scrabble/platform/telegram/internal/render"
|
||||
)
|
||||
|
||||
@@ -30,19 +31,21 @@ type Sender interface {
|
||||
// Server implements telegramv1.TelegramServer.
|
||||
type Server struct {
|
||||
telegramv1.UnimplementedTelegramServer
|
||||
validator initdata.Validator
|
||||
sender Sender
|
||||
channelID int64
|
||||
log *zap.Logger
|
||||
validator initdata.Validator
|
||||
widgetValidator loginwidget.Validator
|
||||
sender Sender
|
||||
channelID int64
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// NewServer builds the gRPC service from a validator (for ValidateInitData), a
|
||||
// sender (the bot), and the configured game channel id (0 disables channel posts).
|
||||
func NewServer(validator initdata.Validator, sender Sender, channelID int64, log *zap.Logger) *Server {
|
||||
// NewServer builds the gRPC service from the Mini App initData validator, the Login
|
||||
// Widget validator (Stage 11), a sender (the bot), and the configured game channel
|
||||
// id (0 disables channel posts).
|
||||
func NewServer(validator initdata.Validator, widgetValidator loginwidget.Validator, sender Sender, channelID int64, log *zap.Logger) *Server {
|
||||
if log == nil {
|
||||
log = zap.NewNop()
|
||||
}
|
||||
return &Server{validator: validator, sender: sender, channelID: channelID, log: log}
|
||||
return &Server{validator: validator, widgetValidator: widgetValidator, sender: sender, channelID: channelID, log: log}
|
||||
}
|
||||
|
||||
// ValidateInitData verifies Mini App launch data and returns the user identity.
|
||||
@@ -59,6 +62,20 @@ func (s *Server) ValidateInitData(ctx context.Context, req *telegramv1.ValidateI
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateLoginWidget verifies Login Widget authorization data and returns the user
|
||||
// identity, for attaching a Telegram identity to an existing account (Stage 11).
|
||||
func (s *Server) ValidateLoginWidget(ctx context.Context, req *telegramv1.ValidateLoginWidgetRequest) (*telegramv1.ValidateLoginWidgetResponse, error) {
|
||||
u, err := s.widgetValidator.Validate(req.GetData())
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
}
|
||||
return &telegramv1.ValidateLoginWidgetResponse{
|
||||
ExternalId: u.ExternalID,
|
||||
Username: u.Username,
|
||||
FirstName: u.FirstName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Notify renders and delivers an out-of-app notification. It reports
|
||||
// delivered=false (without an error) for a kind that is not pushed out-of-app or a
|
||||
// delivery the bot could not complete (e.g. the user never started the bot), so the
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"scrabble/pkg/fbs/scrabblefb"
|
||||
telegramv1 "scrabble/pkg/proto/telegram/v1"
|
||||
"scrabble/platform/telegram/internal/initdata"
|
||||
"scrabble/platform/telegram/internal/loginwidget"
|
||||
)
|
||||
|
||||
// stubValidator returns a fixed user / error from Validate.
|
||||
@@ -21,6 +22,14 @@ type stubValidator struct {
|
||||
|
||||
func (s stubValidator) Validate(string) (initdata.User, error) { return s.user, s.err }
|
||||
|
||||
// stubWidgetValidator returns a fixed user / error from the Login Widget Validate.
|
||||
type stubWidgetValidator struct {
|
||||
user loginwidget.User
|
||||
err error
|
||||
}
|
||||
|
||||
func (s stubWidgetValidator) Validate(string) (loginwidget.User, error) { return s.user, s.err }
|
||||
|
||||
// fakeSender records the delivery calls the server makes.
|
||||
type fakeSender struct {
|
||||
notify []notifyCall
|
||||
@@ -58,7 +67,7 @@ func yourTurnPayload(gameID string) []byte {
|
||||
|
||||
func TestValidateInitData(t *testing.T) {
|
||||
want := initdata.User{ExternalID: "42", Username: "neo", FirstName: "Thomas", LanguageCode: "ru"}
|
||||
srv := NewServer(stubValidator{user: want}, &fakeSender{}, 0, nil)
|
||||
srv := NewServer(stubValidator{user: want}, stubWidgetValidator{}, &fakeSender{}, 0, nil)
|
||||
resp, err := srv.ValidateInitData(context.Background(), &telegramv1.ValidateInitDataRequest{InitData: "x"})
|
||||
if err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
@@ -67,16 +76,33 @@ func TestValidateInitData(t *testing.T) {
|
||||
t.Errorf("resp = %+v, want %+v", resp, want)
|
||||
}
|
||||
|
||||
bad := NewServer(stubValidator{err: initdata.ErrInvalidInitData}, &fakeSender{}, 0, nil)
|
||||
bad := NewServer(stubValidator{err: initdata.ErrInvalidInitData}, stubWidgetValidator{}, &fakeSender{}, 0, nil)
|
||||
if _, err := bad.ValidateInitData(context.Background(), &telegramv1.ValidateInitDataRequest{}); status.Code(err) != codes.InvalidArgument {
|
||||
t.Errorf("err code = %v, want InvalidArgument", status.Code(err))
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLoginWidget(t *testing.T) {
|
||||
want := loginwidget.User{ExternalID: "42", Username: "neo", FirstName: "Thomas"}
|
||||
srv := NewServer(stubValidator{}, stubWidgetValidator{user: want}, &fakeSender{}, 0, nil)
|
||||
resp, err := srv.ValidateLoginWidget(context.Background(), &telegramv1.ValidateLoginWidgetRequest{Data: "x"})
|
||||
if err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
if resp.GetExternalId() != "42" || resp.GetUsername() != "neo" || resp.GetFirstName() != "Thomas" {
|
||||
t.Errorf("resp = %+v, want %+v", resp, want)
|
||||
}
|
||||
|
||||
bad := NewServer(stubValidator{}, stubWidgetValidator{err: loginwidget.ErrInvalidLoginWidget}, &fakeSender{}, 0, nil)
|
||||
if _, err := bad.ValidateLoginWidget(context.Background(), &telegramv1.ValidateLoginWidgetRequest{}); status.Code(err) != codes.InvalidArgument {
|
||||
t.Errorf("err code = %v, want InvalidArgument", status.Code(err))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyDelivers(t *testing.T) {
|
||||
const gameID = "7c9e6679-7425-40de-944b-e07fc1f90ae7"
|
||||
sender := &fakeSender{}
|
||||
srv := NewServer(stubValidator{}, sender, 0, nil)
|
||||
srv := NewServer(stubValidator{}, stubWidgetValidator{}, sender, 0, nil)
|
||||
resp, err := srv.Notify(context.Background(), &telegramv1.NotifyRequest{
|
||||
ExternalId: "12345", Kind: "your_turn", Payload: yourTurnPayload(gameID), Language: "en",
|
||||
})
|
||||
@@ -96,7 +122,7 @@ func TestNotifyDelivers(t *testing.T) {
|
||||
|
||||
func TestNotifySkipsUnrenderedKind(t *testing.T) {
|
||||
sender := &fakeSender{}
|
||||
srv := NewServer(stubValidator{}, sender, 0, nil)
|
||||
srv := NewServer(stubValidator{}, stubWidgetValidator{}, sender, 0, nil)
|
||||
resp, err := srv.Notify(context.Background(), &telegramv1.NotifyRequest{
|
||||
ExternalId: "12345", Kind: "opponent_moved", Language: "en",
|
||||
})
|
||||
@@ -112,7 +138,7 @@ func TestNotifySkipsUnrenderedKind(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNotifyInvalidExternalID(t *testing.T) {
|
||||
srv := NewServer(stubValidator{}, &fakeSender{}, 0, nil)
|
||||
srv := NewServer(stubValidator{}, stubWidgetValidator{}, &fakeSender{}, 0, nil)
|
||||
_, err := srv.Notify(context.Background(), &telegramv1.NotifyRequest{
|
||||
ExternalId: "not-a-number", Kind: "your_turn", Payload: yourTurnPayload("g"), Language: "en",
|
||||
})
|
||||
@@ -123,7 +149,7 @@ func TestNotifyInvalidExternalID(t *testing.T) {
|
||||
|
||||
func TestSendToUser(t *testing.T) {
|
||||
sender := &fakeSender{}
|
||||
srv := NewServer(stubValidator{}, sender, 0, nil)
|
||||
srv := NewServer(stubValidator{}, stubWidgetValidator{}, sender, 0, nil)
|
||||
resp, err := srv.SendToUser(context.Background(), &telegramv1.SendToUserRequest{ExternalId: "999", Text: "hi"})
|
||||
if err != nil {
|
||||
t.Fatalf("send to user: %v", err)
|
||||
@@ -135,7 +161,7 @@ func TestSendToUser(t *testing.T) {
|
||||
|
||||
func TestSendToGameChannel(t *testing.T) {
|
||||
t.Run("unconfigured", func(t *testing.T) {
|
||||
srv := NewServer(stubValidator{}, &fakeSender{}, 0, nil)
|
||||
srv := NewServer(stubValidator{}, stubWidgetValidator{}, &fakeSender{}, 0, nil)
|
||||
_, err := srv.SendToGameChannel(context.Background(), &telegramv1.SendToGameChannelRequest{Text: "x"})
|
||||
if status.Code(err) != codes.FailedPrecondition {
|
||||
t.Errorf("err code = %v, want FailedPrecondition", status.Code(err))
|
||||
@@ -143,7 +169,7 @@ func TestSendToGameChannel(t *testing.T) {
|
||||
})
|
||||
t.Run("configured", func(t *testing.T) {
|
||||
sender := &fakeSender{}
|
||||
srv := NewServer(stubValidator{}, sender, 555, nil)
|
||||
srv := NewServer(stubValidator{}, stubWidgetValidator{}, sender, 555, nil)
|
||||
resp, err := srv.SendToGameChannel(context.Background(), &telegramv1.SendToGameChannelRequest{Text: "news"})
|
||||
if err != nil {
|
||||
t.Fatalf("send to channel: %v", err)
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// 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
|
||||
}
|
||||
@@ -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