From d76ec735d4fad3938b9d0ca20b121b685362dd3f Mon Sep 17 00:00:00 2001 From: Maxim Lebedev Date: Fri, 4 Sep 2020 21:42:56 +0500 Subject: [PATCH] :recycle: Refactored login package --- go.mod | 4 +- go.sum | 12 +++-- login/example_test.go | 70 ++++++++++++++++--------- login/login.go | 86 +++++++++++++++++++++++-------- login/login_test.go | 68 ++++++++++--------------- login/utils_test.go | 116 +++++++++++++++++++++--------------------- 6 files changed, 206 insertions(+), 150 deletions(-) diff --git a/go.mod b/go.mod index 0c6c8a8..b2d026b 100644 --- a/go.mod +++ b/go.mod @@ -4,14 +4,14 @@ go 1.12 require ( github.com/Masterminds/semver v1.5.0 + github.com/fasthttp/router v1.3.2 github.com/json-iterator/go v1.1.9 github.com/kirillDanshin/dlog v0.0.0-20170728000807-97d876b12bf9 github.com/kirillDanshin/myutils v0.0.0-20160713214838-182269b1fbcc // indirect - github.com/klauspost/compress v1.10.7 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.1 // indirect github.com/stretchr/testify v1.3.0 - github.com/valyala/fasthttp v1.14.0 + github.com/valyala/fasthttp v1.16.0 golang.org/x/text v0.3.2 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 ) diff --git a/go.sum b/go.sum index 3a539ee..fe9d54d 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fasthttp/router v1.3.2 h1:n9r5QNuJi5z5Sp2vp/0SrawogTjGfYFqTOyP/R8ehNI= +github.com/fasthttp/router v1.3.2/go.mod h1:athTSKMdel0Qhh3W4nB8qn+EPYuyj6YZMUo6ZcXWTgc= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= @@ -13,7 +15,6 @@ github.com/kirillDanshin/dlog v0.0.0-20170728000807-97d876b12bf9 h1:mA7k8E2Vrmyj github.com/kirillDanshin/dlog v0.0.0-20170728000807-97d876b12bf9/go.mod h1:l8CN7iyX1k2xlsTYVTpCtwBPcxThf/jLWDGVcF6T/bM= github.com/kirillDanshin/myutils v0.0.0-20160713214838-182269b1fbcc h1:OkOhOn3WBUmfATC1NsA3rBlgHGkjk0KGnR5akl/8uXc= github.com/kirillDanshin/myutils v0.0.0-20160713214838-182269b1fbcc/go.mod h1:Bt95qRxLvpdmASW9s2tTxGdQ5ma4o4n8QFhCvzCew/M= -github.com/klauspost/compress v1.10.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.10.7 h1:7rix8v8GpI3ZBb0nSozFRgbtXKv+hOe+qfEpZqybrAg= github.com/klauspost/compress v1.10.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -24,18 +25,21 @@ github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9 github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/savsgio/gotils v0.0.0-20200616100644-13ff1fd2c28c h1:KKqhycXW1WVNkX7r4ekTV2gFkbhdyihlWD8c0/FiWmk= +github.com/savsgio/gotils v0.0.0-20200616100644-13ff1fd2c28c/go.mod h1:TWNAOTaVzGOXq8RbEvHnhzA/A2sLZzgn0m6URjnukY8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.14.0 h1:67bfuW9azCMwW/Jlq/C+VeihNpAuJMWkYPBig1gdi3A= -github.com/valyala/fasthttp v1.14.0/go.mod h1:ol1PCaL0dX20wC0htZ7sYCsvCYmrouYra0zHzaclZhE= +github.com/valyala/fasthttp v1.16.0 h1:9zAqOYLl8Tuy3E5R6ckzGDJ1g8+pw15oQp2iL9Jl6gQ= +github.com/valyala/fasthttp v1.16.0/go.mod h1:YOKImeEosDdBPnxc0gy7INqi3m1zK6A+xl6TwOBhHCA= github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200602114024-627f9648deb9/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= diff --git a/login/example_test.go b/login/example_test.go index 15ad462..5ff17c4 100644 --- a/login/example_test.go +++ b/login/example_test.go @@ -1,44 +1,68 @@ package login_test -/* import ( + "fmt" "log" - httprouter "github.com/buaazp/fasthttprouter" + "github.com/fasthttp/router" http "github.com/valyala/fasthttp" "gitlab.com/toby3d/telegram/login" ) +const htmlTemplate string = ` + + + + + Telegram login + + + + +` + func Example_fastStart() { - // We use bot AccessToken from @BotFather or telegram.Bot structure - botAccessToken := "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" + // Use bot AccessToken from @BotFather as ClientSecret. + c := login.Config{ + ClientSecret: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", + RedirectURL: "https://example.site/callback", + RequestWriteAccess: true, + } - // Create example server with example callback handler - r := httprouter.New() + // Create example server with authorization and token (callback) handlers. + r := router.New() + r.GET("/", func(ctx *http.RequestCtx) { + // Render page with embeded Telegram Login button (until Telegram enable the possibility of login by + // link.) + ctx.SuccessString("text/html", htmlTemplate) + + // NOTE(toby3d): Telegram does not yet allow you to login without script via a link, as is common + // in traditional OAuth2 applications, stopping at the last step with redirect to callback. The + // 'embed=[0|1]' parameter has no effect now, which is very similar to a bug. + //ctx.SuccessString("text/html", fmt.Sprintf(htmlTemplate, c.AuthCodeURL(language.English))) + }) r.GET("/callback", func(ctx *http.RequestCtx) { - defer ctx.SetConnectionClose() + q := ctx.QueryArgs() + u := login.User{ + AuthDate: int64(q.GetUintOrZero(login.KeyAuthDate)), + FirstName: string(q.Peek(login.KeyFirstName)), + Hash: string(q.Peek(login.KeyHash)), + ID: q.GetUintOrZero(login.KeyID), + LastName: string(q.Peek(login.KeyLastName)), + PhotoURL: string(q.Peek(login.KeyPhotoURL)), + Username: string(q.Peek(login.KeyUsername)), + } - // You not need decode data to User structure if you want only - // validate it - u, err := login.ParseUser(ctx.QueryArgs()) - if err != nil { - ctx.Error("bad request", http.StatusBadRequest) + if !c.Verify(&u) { + ctx.Error("Unable to verify data", http.StatusUnauthorized) return } - // Check User structure - ok, err := login.CheckAuthorization(u, botAccessToken) - if err != nil || !ok { // NANI!? It's a invalid data! - ctx.Error("bad request", http.StatusBadRequest) - return - } - - // All is ok! Hello, human! - ctx.Success("text/html", []byte("hello, "+u.FullName()+"!")) + ctx.SuccessString("text/plain", fmt.Sprintf("Hello, %s!", u.FullName())) }) - if err := http.ListenAndServe(":8000", r.Handler); err != nil { + if err := http.ListenAndServe(":80", r.Handler); err != nil { log.Fatalln(err.Error()) } } -*/ diff --git a/login/login.go b/login/login.go index b5abc4d..1d0c280 100644 --- a/login/login.go +++ b/login/login.go @@ -4,56 +4,98 @@ import ( "crypto/hmac" "crypto/sha256" "encoding/hex" + "net/url" + "strings" http "github.com/valyala/fasthttp" + "golang.org/x/text/language" ) type ( - Widget struct { - accessToken string + Config struct { + // ClientSecret is the bot token. + ClientSecret string + + // RedirectURL is the URL to redirect users going through the login flow. + RedirectURL string + + // RequestWriteAccess request the permission for bot to send messages to the user. + RequestWriteAccess bool } // User contains data about authenticated user. User struct { - ID int `json:"id"` AuthDate int64 `json:"auth_date"` FirstName string `json:"first_name"` Hash string `json:"hash"` + ID int `json:"id"` LastName string `json:"last_name,omitempty"` PhotoURL string `json:"photo_url,omitempty"` Username string `json:"username,omitempty"` } ) +const Endpoint string = "https://oauth.telegram.org/auth" + // Key represents available and supported query arguments keys. const ( - KeyAuthDate = "auth_date" - KeyFirstName = "first_name" - KeyHash = "hash" - KeyID = "id" - KeyLastName = "last_name" - KeyPhotoURL = "photo_url" - KeyUsername = "username" + KeyAuthDate string = "auth_date" + KeyFirstName string = "first_name" + KeyHash string = "hash" + KeyID string = "id" + KeyLastName string = "last_name" + KeyPhotoURL string = "photo_url" + KeyUsername string = "username" ) -func NewWidget(accessToken string) *Widget { - return &Widget{accessToken: accessToken} +// ClientID returns bot ID from it's ClientSecret token. +func (c Config) ClientID() string { + return strings.SplitN(c.ClientSecret, ":", 2)[0] } -// CheckAuthorization verify the authentication and the integrity of the data -// received by comparing the received hash parameter with the hexadecimal -// representation of the HMAC-SHA-256 signature of the data-check-string with the -// SHA256 hash of the bot's token used as a secret key. -func (w Widget) CheckAuthorization(u User) (bool, error) { - hash, err := w.GenerateHash(u) - return hash == u.Hash, err +// AuthCodeURL returns a URL to Telegram login page that asks for permissions for the required scopes explicitly. +func (c *Config) AuthCodeURL(lang language.Tag) string { + origin, _ := url.Parse(c.RedirectURL) + + u := http.AcquireURI() + defer http.ReleaseURI(u) + u.Update(Endpoint) + + a := u.QueryArgs() + a.Set("bot_id", c.ClientID()) + a.Set("origin", origin.Scheme+"://"+origin.Host) + a.Add("embed", "0") + + if lang != language.Und { + a.Set("lang", lang.String()) + } + + if c.RequestWriteAccess { + a.Set("request_access", "write") + } + + return u.String() } -func (w Widget) GenerateHash(u User) (string, error) { +// Verify verify the authentication and the integrity of the data received by +// comparing the received hash parameter with the hexadecimal representation +// of the HMAC-SHA-256 signature of the data-check-string with the SHA256 +// hash of the bot's token used as a secret key. +func (c *Config) Verify(u *User) bool { + if u == nil || u.Hash == "" { + return false + } + + h, err := generateHash(c.ClientSecret, u) + + return err == nil && u.Hash == h +} + +func generateHash(token string, u *User) (string, error) { a := http.AcquireArgs() defer http.ReleaseArgs(a) - // WARN: do not change order of this args, it must be alphabetical + // WARN(toby3d): do not change order of this args, it must be alphabetical a.SetUint(KeyAuthDate, int(u.AuthDate)) a.Set(KeyFirstName, u.FirstName) a.SetUint(KeyID, u.ID) @@ -70,7 +112,7 @@ func (w Widget) GenerateHash(u User) (string, error) { a.Set(KeyUsername, u.Username) } - secretKey := sha256.Sum256([]byte(w.accessToken)) + secretKey := sha256.Sum256([]byte(token)) h := hmac.New(sha256.New, secretKey[0:]) if _, err := h.Write(a.QueryString()); err != nil { diff --git a/login/login_test.go b/login/login_test.go index b71ba9b..51b364b 100644 --- a/login/login_test.go +++ b/login/login_test.go @@ -1,54 +1,38 @@ -package login +package login_test import ( "testing" - "time" "github.com/stretchr/testify/assert" + "gitlab.com/toby3d/telegram/login" + "golang.org/x/text/language" ) -func TestNew(t *testing.T) { - assert.NotNil(t, NewWidget("hackme")) +func TestClientID(t *testing.T) { + c := login.Config{ClientSecret: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"} + assert.Equal(t, "123456", c.ClientID()) } -func TestGenerateHash(t *testing.T) { - w := NewWidget("hackme") - hash, err := w.GenerateHash(User{ - ID: 123, - Username: "toby3d", - FirstName: "Maxim", - LastName: "Lebedev", - AuthDate: time.Now().UTC().Unix(), - }) - assert.NoError(t, err) - assert.NotEmpty(t, hash) -} - -func TestCheckAuthorization(t *testing.T) { - w := NewWidget("hackme") - u := User{ - ID: 123, - Username: "toby3d", - FirstName: "Maxim", - LastName: "Lebedev", - PhotoURL: "https://toby3d.me/avatar.jpg", - AuthDate: time.Now().UTC().Unix(), +func TestAuthCodeURL(t *testing.T) { + c := login.Config{ + ClientSecret: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", + RedirectURL: "https://example.site/callback", + RequestWriteAccess: true, } - t.Run("invalid", func(t *testing.T) { - u.Hash = "wtf" - ok, err := w.CheckAuthorization(u) - assert.NoError(t, err) - assert.False(t, ok) - }) - t.Run("valid", func(t *testing.T) { - var err error - u.Hash, err = w.GenerateHash(u) - assert.NoError(t, err) - assert.NotEmpty(t, u.Hash) - - ok, err := w.CheckAuthorization(u) - assert.NoError(t, err) - assert.True(t, ok) - }) + assert.Equal(t, "https://oauth.telegram.org/auth?bot_id=123456&origin=https%3A%2F%2Fexample.site"+ + "&embed=0&lang=ru&request_access=write", c.AuthCodeURL(language.Russian)) +} + +func TestVerify(t *testing.T) { + c := login.Config{ClientSecret: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"} + assert.True(t, c.Verify(&login.User{ + ID: 123456, + Username: "toby3d", + FirstName: "Maxim", + LastName: "Lebedev", + PhotoURL: "https://t.me/i/userpic/320/ABC-DEF1234ghIkl-zyx57W2v1u123ew11.jpg", + AuthDate: 1410696795, + Hash: "d9b74e929cd4cfa7299031421db61949ecd49641c3b06e3a0361f593cf1fe064", + })) } diff --git a/login/utils_test.go b/login/utils_test.go index a384272..4571ec4 100644 --- a/login/utils_test.go +++ b/login/utils_test.go @@ -7,64 +7,66 @@ import ( "github.com/stretchr/testify/assert" ) -func TestUser(t *testing.T) { - t.Run("has last name", func(t *testing.T) { - t.Run("false", func(t *testing.T) { - var u User - assert.False(t, u.HasLastName()) - }) - t.Run("true", func(t *testing.T) { - u := User{LastName: "Lebedev"} - assert.True(t, u.HasLastName()) - }) +func TestUserFullName(t *testing.T) { + t.Run("empty", func(t *testing.T) { + var u User + assert.Empty(t, u.FullName()) }) - t.Run("has username", func(t *testing.T) { - t.Run("false", func(t *testing.T) { - var u User - assert.False(t, u.HasUsername()) - }) - t.Run("true", func(t *testing.T) { - u := User{Username: "toby3d"} - assert.True(t, u.HasUsername()) - }) + t.Run("first name", func(t *testing.T) { + u := User{ + FirstName: "Maxim", + } + assert.Equal(t, u.FirstName, u.FullName()) }) - t.Run("auth time", func(t *testing.T) { - t.Run("empty", func(t *testing.T) { - var u User - assert.True(t, u.AuthTime().IsZero()) - }) - t.Run("exists", func(t *testing.T) { - u := User{AuthDate: time.Now().UTC().Unix()} - assert.False(t, u.AuthTime().IsZero()) - }) - }) - t.Run("has photo", func(t *testing.T) { - t.Run("empty", func(t *testing.T) { - var u User - assert.False(t, u.HasPhoto()) - }) - t.Run("exists", func(t *testing.T) { - u := User{PhotoURL: "https://toby3d.me/avatar.jpg"} - assert.True(t, u.HasPhoto()) - }) - }) - t.Run("full name", func(t *testing.T) { - t.Run("empty", func(t *testing.T) { - var u User - assert.Empty(t, u.FullName()) - }) - t.Run("first name", func(t *testing.T) { - u := User{ - FirstName: "Maxim", - } - assert.Equal(t, u.FirstName, u.FullName()) - }) - t.Run("first & last name", func(t *testing.T) { - u := User{ - FirstName: "Maxim", - LastName: "Lebedev", - } - assert.Equal(t, u.FirstName+" "+u.LastName, u.FullName()) - }) + t.Run("first & last name", func(t *testing.T) { + u := User{ + FirstName: "Maxim", + LastName: "Lebedev", + } + assert.Equal(t, u.FirstName+" "+u.LastName, u.FullName()) + }) +} + +func TestUserAuthTime(t *testing.T) { + t.Run("empty", func(t *testing.T) { + var u User + assert.True(t, u.AuthTime().IsZero()) + }) + t.Run("exists", func(t *testing.T) { + u := User{AuthDate: time.Now().UTC().Unix()} + assert.False(t, u.AuthTime().IsZero()) + }) +} + +func TestUserhasLastName(t *testing.T) { + t.Run("false", func(t *testing.T) { + var u User + assert.False(t, u.HasLastName()) + }) + t.Run("true", func(t *testing.T) { + u := User{LastName: "Lebedev"} + assert.True(t, u.HasLastName()) + }) +} + +func TestUserHasUsername(t *testing.T) { + t.Run("false", func(t *testing.T) { + var u User + assert.False(t, u.HasUsername()) + }) + t.Run("true", func(t *testing.T) { + u := User{Username: "toby3d"} + assert.True(t, u.HasUsername()) + }) +} + +func TestUserHasPhoto(t *testing.T) { + t.Run("empty", func(t *testing.T) { + var u User + assert.False(t, u.HasPhoto()) + }) + t.Run("exists", func(t *testing.T) { + u := User{PhotoURL: "https://toby3d.me/avatar.jpg"} + assert.True(t, u.HasPhoto()) }) }