diff --git a/webapps.go b/webapps.go new file mode 100644 index 0000000..918bce1 --- /dev/null +++ b/webapps.go @@ -0,0 +1,214 @@ +package tg + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "fmt" + "net/url" + "strconv" + "strings" + "time" + + "golang.org/x/exp/maps" + "golang.org/x/exp/slices" +) + +func getDataCheckString(vs url.Values) string { + keys := maps.Keys(vs) + slices.Sort(keys) + + parts := make([]string, len(keys)) + for i, k := range keys { + parts[i] = k + "=" + vs.Get(k) + } + + return strings.Join(parts, "\n") +} + +// AuthWidget represents Telegram Login Widget data. +// +// See https://core.telegram.org/widgets/login#receiving-authorization-data for more information. +type AuthWidget struct { + ID UserID `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name,omitempty"` + Username string `json:"username,omitempty"` + PhotoURL string `json:"photo_url,omitempty"` + AuthDate int64 `json:"auth_date"` + Hash string `json:"hash"` +} + +// ParseAuthWidgetQuery parses a query string and returns an AuthWidget. +func ParseAuthWidgetQuery(vs url.Values) (*AuthWidget, error) { + result := &AuthWidget{} + + id, err := strconv.ParseInt(vs.Get("id"), 10, 64) + if err != nil { + return nil, fmt.Errorf("parse id %s: %w", vs.Get("id"), err) + } + result.ID = UserID(id) + + result.FirstName = vs.Get("first_name") + + authDate, err := strconv.ParseInt(vs.Get("auth_date"), 10, 64) + if err != nil { + return nil, fmt.Errorf("parse auth_date %s: %w", vs.Get("auth_date"), err) + } + result.AuthDate = authDate + + result.Hash = vs.Get("hash") + + result.LastName = vs.Get("last_name") + result.Username = vs.Get("username") + result.PhotoURL = vs.Get("photo_url") + + return result, nil +} + +// Query returns a query values for the widget. +func (w AuthWidget) Query() url.Values { + q := url.Values{} + q.Set("id", strconv.FormatInt(int64(w.ID), 10)) + q.Set("first_name", w.FirstName) + q.Set("auth_date", strconv.FormatInt(w.AuthDate, 10)) + q.Set("hash", w.Hash) + + if w.LastName != "" { + q.Set("last_name", w.LastName) + } + + if w.Username != "" { + q.Set("username", w.Username) + } + + if w.PhotoURL != "" { + q.Set("photo_url", w.PhotoURL) + } + + return q +} + +// Valid returns true if the signature is valid. +func (w AuthWidget) Valid(token string) bool { + return subtle.ConstantTimeCompare( + []byte(w.Signature(token)), + []byte(w.Hash), + ) == 1 +} + +// Signature returns the signature of the widget data. +func (w AuthWidget) Signature(token string) string { + vs := w.Query() + + vs.Del("hash") + + data := getDataCheckString(vs) + + key := sha256.Sum256([]byte(token)) + + return hex.EncodeToString(getHMAC(data, key[:])) +} + +func getHMAC(data string, key []byte) []byte { + mac := hmac.New(sha256.New, key) + mac.Write([]byte(data)) + return mac.Sum(nil) +} + +// AuthDateTime returns the AuthDate as a time.Time. +func (w AuthWidget) AuthDateTime() time.Time { + return time.Unix(w.AuthDate, 0) +} + +// ParseWebAppInitData parses a WebAppInitData from query string. +func ParseWebAppInitData(vs url.Values) (*WebAppInitData, error) { + result := &WebAppInitData{} + + result.QueryID = vs.Get("query_id") + if result.QueryID == "" { + return nil, fmt.Errorf("query_id is empty") + } + + if vs.Has("user") { + var user *WebAppUser + if err := json.Unmarshal([]byte(vs.Get("user")), &user); err != nil { + return nil, fmt.Errorf("parse user: %w", err) + } + result.User = user + } + + if vs.Has("receiver") { + var receiver *WebAppUser + if err := json.Unmarshal([]byte(vs.Get("receiver")), &receiver); err != nil { + return nil, fmt.Errorf("parse receiver: %w", err) + } + result.Receiver = receiver + } + + if vs.Has("chat") { + var chat *WebAppChat + if err := json.Unmarshal([]byte(vs.Get("chat")), &chat); err != nil { + return nil, fmt.Errorf("parse chat: %w", err) + } + result.Chat = chat + } + + result.StartParam = vs.Get("start_param") + + if vs.Has("can_send_after") { + canSendAfter, err := strconv.Atoi(vs.Get("can_send_after")) + if err != nil { + return nil, fmt.Errorf("parse can_send_after: %w", err) + } + result.CanSendAfter = canSendAfter + } + + authDate, err := strconv.ParseInt(vs.Get("auth_date"), 10, 64) + if err != nil { + return nil, fmt.Errorf("parse auth_date %s: %w", vs.Get("auth_date"), err) + } + + result.AuthDate = authDate + + result.Hash = vs.Get("hash") + if result.Hash == "" { + return nil, fmt.Errorf("hash is empty") + } + + result.raw = vs + + return result, nil +} + +// Signature returns the signature of the WebAppInitData. +func (w WebAppInitData) Signature(token string) string { + vs := w.Query() + + vs.Del("hash") + + data := getDataCheckString(vs) + + key := getHMAC(token, []byte("WebAppData")) + + return hex.EncodeToString(getHMAC(data, key)) +} + +// Query returns a query values for the WebAppInitData. +func (w WebAppInitData) Query() url.Values { + vs := make(url.Values, len(w.raw)) + maps.Copy(vs, w.raw) + + vs.Del("hash") + + return vs +} + +func (w WebAppInitData) Valid(token string) bool { + return subtle.ConstantTimeCompare( + []byte(w.Signature(token)), + []byte(w.Hash), + ) == 1 +} diff --git a/webapps_test.go b/webapps_test.go new file mode 100644 index 0000000..0778a84 --- /dev/null +++ b/webapps_test.go @@ -0,0 +1,357 @@ +package tg + +import ( + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestAuthWidget_Query(t *testing.T) { + for _, test := range []struct { + Widget AuthWidget + Want url.Values + }{ + { + Widget: AuthWidget{ + ID: UserID(1), + FirstName: "John", + AuthDate: 1546300800, + Hash: "hash", + }, + + Want: url.Values{ + "id": []string{"1"}, + "first_name": []string{"John"}, + "auth_date": []string{"1546300800"}, + "hash": []string{"hash"}, + }, + }, + { + Widget: AuthWidget{ + ID: UserID(1), + FirstName: "John", + LastName: "Doe", + Username: "jdoe", + PhotoURL: "https://example.com/photo.jpg", + AuthDate: 1546300800, + Hash: "hash", + }, + + Want: url.Values{ + "id": []string{"1"}, + "first_name": []string{"John"}, + "last_name": []string{"Doe"}, + "username": []string{"jdoe"}, + "photo_url": []string{"https://example.com/photo.jpg"}, + "auth_date": []string{"1546300800"}, + "hash": []string{"hash"}, + }, + }, + } { + got := test.Widget.Query() + + assert.Equal(t, test.Want, got) + } +} + +func TestParseAuthWidgetQuery(t *testing.T) { + for _, test := range []struct { + Name string + Values url.Values + Excepted *AuthWidget + Error bool + }{ + { + Name: "AllValid", + Values: url.Values{ + "id": []string{"1"}, + "first_name": []string{"John"}, + "last_name": []string{"Doe"}, + "username": []string{"jdoe"}, + "photo_url": []string{"https://example.com/photo.jpg"}, + "auth_date": []string{"1546300800"}, + "hash": []string{"hash"}, + }, + Excepted: &AuthWidget{ + ID: UserID(1), + FirstName: "John", + LastName: "Doe", + Username: "jdoe", + PhotoURL: "https://example.com/photo.jpg", + AuthDate: 1546300800, + Hash: "hash", + }, + Error: false, + }, + { + Name: "InvalidID", + Values: url.Values{ + "id": []string{"invalid"}, + "first_name": []string{"John"}, + "last_name": []string{"Doe"}, + "username": []string{"jdoe"}, + "photo_url": []string{"https://example.com/photo.jpg"}, + "auth_date": []string{"1546300800"}, + "hash": []string{"hash"}, + }, + Error: true, + }, + { + Name: "InvalidAuthDate", + Values: url.Values{ + "id": []string{"1"}, + "first_name": []string{"John"}, + "last_name": []string{"Doe"}, + "username": []string{"jdoe"}, + "photo_url": []string{"https://example.com/photo.jpg"}, + "auth_date": []string{"invalid"}, + "hash": []string{"hash"}, + }, + Error: true, + }, + } { + t.Run(test.Name, func(t *testing.T) { + got, err := ParseAuthWidgetQuery(test.Values) + if test.Error { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + + assert.Equal(t, test.Excepted, got) + + }) + } +} + +// https://dcd3-91-235-226-78.eu.ngrok.io/login-url?id=103980787&first_name=Sasha&username=MrLinch&photo_url=https%3A%2F%2Ft.me%2Fi%2Fuserpic%2F320%2Fq9a3ePyQ_J58XivHA6pL7UOLZWvphbLgBqh3OLhmtrs.jpg&auth_date=1656790495&hash=d64920549aa64c3f69577e217e77b253ca383bf0b9945266ab5e096739250d2d + +func TestAuthWidget_Signature(t *testing.T) { + w := AuthWidget{ + ID: 103980787, + FirstName: "Sasha", + Username: "MrLinch", + PhotoURL: "https://t.me/i/userpic/320/q9a3ePyQ_J58XivHA6pL7UOLZWvphbLgBqh3OLhmtrs.jpg", + AuthDate: 1656790495, + Hash: "d64920549aa64c3f69577e217e77b253ca383bf0b9945266ab5e096739250d2d", + } + + signature := w.Signature("5433024556:AAF63JW91kEl7k8bhqBzu86niebek4ldogg") + + assert.Equal(t, w.Hash, signature) +} + +func TestAuthWidget_Valid(t *testing.T) { + token := "5433024556:AAF63JW91kEl7k8bhqBzu86niebek4ldogg" + + t.Run("Ok", func(t *testing.T) { + w := AuthWidget{ + ID: 103980787, + FirstName: "Sasha", + Username: "MrLinch", + PhotoURL: "https://t.me/i/userpic/320/q9a3ePyQ_J58XivHA6pL7UOLZWvphbLgBqh3OLhmtrs.jpg", + AuthDate: 1656790495, + Hash: "d64920549aa64c3f69577e217e77b253ca383bf0b9945266ab5e096739250d2d", + } + + assert.True(t, w.Valid(token)) + }) + + t.Run("False", func(t *testing.T) { + w := AuthWidget{ + ID: 103980786, + FirstName: "Sasha", + Username: "MrLinch", + PhotoURL: "https://t.me/i/userpic/320/q9a3ePyQ_J58XivHA6pL7UOLZWvphbLgBqh3OLhmtrs.jpg", + AuthDate: 1656790495, + Hash: "d64920549aa64c3f69577e217e77b253ca383bf0b9945266ab5e096739250d2d", + } + + assert.False(t, w.Valid(token)) + }) + +} + +func TestAuthWidget_AuthDateTime(t *testing.T) { + now := time.Now().Truncate(time.Second) + + w := AuthWidget{ + AuthDate: now.Unix(), + } + + assert.Equal(t, now, w.AuthDateTime()) +} + +func TestParseWebAppInitData(t *testing.T) { + for _, test := range []struct { + Name string + Values url.Values + Excepted *WebAppInitData + Error bool + }{ + { + Name: "AllValid", + Values: url.Values{ + "query_id": []string{"1"}, + "user": []string{`{"id": 1}`}, + "receiver": []string{`{"id": 2}`}, + "chat": []string{`{"id": 3}`}, + "start_param": []string{"start_param"}, + "can_send_after": []string{"10"}, + "auth_date": []string{"1546300800"}, + "hash": []string{"hash"}, + }, + Excepted: &WebAppInitData{ + QueryID: "1", + User: &WebAppUser{ID: 1}, + Receiver: &WebAppUser{ID: 2}, + Chat: &WebAppChat{ID: 3}, + StartParam: "start_param", + CanSendAfter: 10, + AuthDate: 1546300800, + Hash: "hash", + }, + }, + { + Name: "NoQueryID", + Values: url.Values{ + "user": []string{`{"id": 1}`}, + "receiver": []string{`{"id": 2}`}, + "chat": []string{`{"id": 3}`}, + "start_param": []string{"start_param"}, + "can_send_after": []string{"10"}, + "auth_date": []string{"1546300800"}, + "hash": []string{"hash"}, + }, + Error: true, + }, + { + Name: "NoQueryID", + Values: url.Values{ + "query_id": []string{"1"}, + "user": []string{`{"id": 1}`}, + "receiver": []string{`{"id": 2}`}, + "chat": []string{`{"id": 3}`}, + "start_param": []string{"start_param"}, + "can_send_after": []string{"10"}, + "auth_date": []string{"1546300800"}, + }, + Error: true, + }, + { + Name: "InvalidUser", + Values: url.Values{ + "query_id": []string{"1"}, + "user": []string{`{"id": "asda"}`}, + "receiver": []string{`{"id": 2}`}, + "chat": []string{`{"id": 3}`}, + "start_param": []string{"start_param"}, + "can_send_after": []string{"invalid"}, + "auth_date": []string{"1546300800"}, + "hash": []string{"hash"}, + }, + Error: true, + }, + { + Name: "InvalidReceiver", + Values: url.Values{ + "query_id": []string{"1"}, + "user": []string{`{"id": 1}`}, + "receiver": []string{`{"id": "asdv"}`}, + "chat": []string{`{"id": 3}`}, + "start_param": []string{"start_param"}, + "can_send_after": []string{"invalid"}, + "auth_date": []string{"1546300800"}, + "hash": []string{"hash"}, + }, + Error: true, + }, + { + Name: "InvalidChat", + Values: url.Values{ + "query_id": []string{"1"}, + "user": []string{`{"id": 1}`}, + "receiver": []string{`{"id": 2}`}, + "chat": []string{`{"id": "asdv"}`}, + "start_param": []string{"start_param"}, + "can_send_after": []string{"invalid"}, + "auth_date": []string{"1546300800"}, + "hash": []string{"hash"}, + }, + Error: true, + }, + { + Name: "InvalidCanSendAfter", + Values: url.Values{ + "query_id": []string{"1"}, + "user": []string{`{"id": 1}`}, + "receiver": []string{`{"id": 2}`}, + "chat": []string{`{"id": 3}`}, + "start_param": []string{"start_param"}, + "can_send_after": []string{"invalid"}, + "auth_date": []string{"1546300800"}, + "hash": []string{"hash"}, + }, + Error: true, + }, + { + Name: "InvalidAuthDate", + Values: url.Values{ + "query_id": []string{"1"}, + "user": []string{`{"id": 1}`}, + "receiver": []string{`{"id": 2}`}, + "chat": []string{`{"id": 3}`}, + "start_param": []string{"start_param"}, + "can_send_after": []string{"10"}, + "auth_date": []string{"invalid"}, + "hash": []string{"hash"}, + }, + Error: true, + }, + } { + t.Run(test.Name, func(t *testing.T) { + if test.Excepted != nil { + test.Excepted.raw = test.Values + } + + got, err := ParseWebAppInitData(test.Values) + + if test.Error { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + + assert.Equal(t, test.Excepted, got) + + }) + } +} + +func TestWebAppInitData_Valid(t *testing.T) { + const token = "5433024556:AAF63JW91kEl7k8bhqBzu86niebek4ldogg" + + t.Run("True", func(t *testing.T) { + vs, err := url.ParseQuery("query_id=AAHznjIGAAAAAPOeMgZUHjBo&user=%7B%22id%22%3A103980787%2C%22first_name%22%3A%22Sasha%22%2C%22last_name%22%3A%22%22%2C%22username%22%3A%22MrLinch%22%2C%22language_code%22%3A%22uk%22%7D&auth_date=1656798871&hash=8c59e353f627a5c67d41f8a2e8f8c12d9e0fbec8ac44680d779ebed3c326a41a") + assert.NoError(t, err) + + got, err := ParseWebAppInitData(vs) + assert.NoError(t, err) + assert.NotNil(t, got) + + assert.True(t, got.Valid(token)) + }) + t.Run("False", func(t *testing.T) { + vs, err := url.ParseQuery("query_id=AAAHznjIGAAAAAPOeMgZUHjBo&user=%7B%22id%22%3A103980787%2C%22first_name%22%3A%22Sasha%22%2C%22last_name%22%3A%22%22%2C%22username%22%3A%22MrLinch%22%2C%22language_code%22%3A%22uk%22%7D&auth_date=1656798871&hash=8c59e353f627a5c67d41f8a2e8f8c12d9e0fbec8ac44680d779ebed3c326a41a") + assert.NoError(t, err) + + got, err := ParseWebAppInitData(vs) + assert.NoError(t, err) + assert.NotNil(t, got) + + assert.False(t, got.Valid(token)) + }) + +}