From d18194e972d1606b63cb0c7406a8d57912e53cf0 Mon Sep 17 00:00:00 2001 From: zdunecki Date: Thu, 4 Mar 2021 16:05:46 +0100 Subject: [PATCH 1/3] add livechat provider --- README.md | 1 + examples/main.go | 4 + providers/livechat/livechat.go | 223 ++++++++++++++++++++++++++++ providers/livechat/livechat_test.go | 51 +++++++ providers/livechat/session.go | 61 ++++++++ providers/livechat/session_test.go | 46 ++++++ 6 files changed, 386 insertions(+) create mode 100644 providers/livechat/livechat.go create mode 100644 providers/livechat/livechat_test.go create mode 100644 providers/livechat/session.go create mode 100644 providers/livechat/session_test.go diff --git a/README.md b/README.md index d3f1088e0..a0530a7b5 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ $ go get github.com/markbates/goth * Lastfm * Linkedin * LINE +* LiveChat * Mailru * Meetup * MicrosoftOnline diff --git a/examples/main.go b/examples/main.go index c56119c0b..05b9b33bb 100644 --- a/examples/main.go +++ b/examples/main.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "github.com/markbates/goth/providers/livechat" "html/template" "net/http" "os" @@ -84,6 +85,8 @@ func main() { spotify.New(os.Getenv("SPOTIFY_KEY"), os.Getenv("SPOTIFY_SECRET"), "http://localhost:3000/auth/spotify/callback"), linkedin.New(os.Getenv("LINKEDIN_KEY"), os.Getenv("LINKEDIN_SECRET"), "http://localhost:3000/auth/linkedin/callback"), line.New(os.Getenv("LINE_KEY"), os.Getenv("LINE_SECRET"), "http://localhost:3000/auth/line/callback", "profile", "openid", "email"), + livechat.New(os.Getenv("LIVECHAT_KEY"), os.Getenv("LIVECHAT_SECRET"), "http://localhost:3000/auth/livechat/callback", livechat.WithConsent()), + lastfm.New(os.Getenv("LASTFM_KEY"), os.Getenv("LASTFM_SECRET"), "http://localhost:3000/auth/lastfm/callback"), twitch.New(os.Getenv("TWITCH_KEY"), os.Getenv("TWITCH_SECRET"), "http://localhost:3000/auth/twitch/callback"), dropbox.New(os.Getenv("DROPBOX_KEY"), os.Getenv("DROPBOX_SECRET"), "http://localhost:3000/auth/dropbox/callback"), @@ -180,6 +183,7 @@ func main() { m["lastfm"] = "Last FM" m["linkedin"] = "Linkedin" m["line"] = "LINE" + m["livechat"] = "LiveChat" m["onedrive"] = "Onedrive" m["azuread"] = "Azure AD" m["microsoftonline"] = "Microsoft Online" diff --git a/providers/livechat/livechat.go b/providers/livechat/livechat.go new file mode 100644 index 000000000..5d9c4fe0d --- /dev/null +++ b/providers/livechat/livechat.go @@ -0,0 +1,223 @@ +// Package livechat implements the OAuth protocol for authenticating users through Livechat. +package livechat + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "github.com/markbates/goth" + "golang.org/x/oauth2" + "net/http" + "strings" +) + +const ( + authURL = "https://accounts.livechat.com" + tokenURL = "https://accounts.livechat.com/v2/token" + userURL = "https://accounts.livechat.com/v2/accounts/me" + defaultProviderName = "livechat" +) + +// Account represents LiveChat account +type Account struct { + ID string `json:"account_id"` + Email string `json:"email"` + Name string `json:"name"` + Link string `json:"link"` + EmailVerified bool `json:"email_verified"` + AvatarURL string `json:"avatar_url"` + OrganizationID string `json:"organization_id"` +} + +type RawUserData struct { + Region string `json:"region"` + OrganizationID string `json:"organization_id"` +} + +// Provider is the implementation of `goth.Provider` for accessing Livechat +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + + config *oauth2.Config + providerName string + + consent bool +} + +type Option func(p *Provider) + +func WithConsent() Option { + return func(p *Provider) { + p.consent = true + } +} + +// New creates the new Livechat provider +func New(clientKey, secret, callbackURL string, opts ...Option) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: defaultProviderName, + } + p.config = newConfig(p) + + for _, o := range opts { + o(p) + } + return p +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the livechat package +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Livechat for an authentication end-point +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + var opts []oauth2.AuthCodeOption + + if p.consent { + opts = append(opts, oauth2.SetAuthURLParam("prompt", "consent")) + } + + url := p.config.AuthCodeURL(state, opts...) + session := &Session{ + AuthURL: url, + } + return session, nil +} + +// FetchUser will fetch basic information about Livechat user +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + s := session.(*Session) + user := goth.User{ + AccessToken: s.AccessToken, + RefreshToken: s.RefreshToken, + Provider: p.Name(), + ExpiresAt: s.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + account, err := FetchAccount(p.Client(), s.AccessToken) + if err != nil { + return user, err + } + setGothUser(account, &user) + + parts := strings.Split(s.AccessToken, ":") + if len(parts) != 2 { + return user, errors.New("invalid_region") + } + + var userDataMap map[string]interface{} + { + userData := &RawUserData{ + Region: parts[0], + OrganizationID: account.OrganizationID, + } + + jUserData, _ := json.Marshal(userData) + json.Unmarshal(jUserData, &userDataMap) + } + + user.RawData = userDataMap + + return user, err +} + +func FetchAccount(c *http.Client, accessToken string) (*Account, error) { + if c == nil { + c = http.DefaultClient + } + req, err := http.NewRequest("GET", userURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + resp, err := c.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var account *Account + + if err := json.NewDecoder(resp.Body).Decode(&account); err != nil { + return nil, err + } + + return account, nil +} + +func setGothUser(a *Account, user *goth.User) { + user.UserID = a.ID + user.Name = a.Name + user.FirstName, user.LastName = splitName(a.Name) + user.Email = a.Email + user.AvatarURL = a.AvatarURL +} + +func splitName(name string) (string, string) { + nameSplit := strings.SplitN(name, " ", 2) + firstName := nameSplit[0] + + var lastName string + if len(nameSplit) == 2 { + lastName = nameSplit[1] + } + + return firstName, lastName +} + +func newConfig(provider *Provider) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + // The list of resources and actions is automatically created based on the scopes selected for your app in Developer Console + Scopes: []string{}, + } + + return c +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(context.Background(), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} diff --git a/providers/livechat/livechat_test.go b/providers/livechat/livechat_test.go new file mode 100644 index 000000000..11cff5d84 --- /dev/null +++ b/providers/livechat/livechat_test.go @@ -0,0 +1,51 @@ +package livechat + +import ( + "github.com/markbates/goth" + "github.com/stretchr/testify/assert" + "os" + "testing" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("LIVECHAT_KEY")) + a.Equal(p.Secret, os.Getenv("LIVECHAT_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*Session) + a.NoError(err) + a.Contains(s.AuthURL, "accounts.livechat.com") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://accounts.livechat.com","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*Session) + a.Equal(s.AuthURL, "https://accounts.livechat.com") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *Provider { + return New(os.Getenv("LIVECHAT_KEY"), os.Getenv("LIVECHAT_SECRET"), "/foo") +} diff --git a/providers/livechat/session.go b/providers/livechat/session.go new file mode 100644 index 000000000..a06160190 --- /dev/null +++ b/providers/livechat/session.go @@ -0,0 +1,61 @@ +package livechat + +import ( + "encoding/json" + "errors" + "github.com/markbates/goth" + "golang.org/x/oauth2" + "strings" + "time" +) + +// Session stores data during the auth process with intercom. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the intercom provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with intercom and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(oauth2.NoContext, params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + sess := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(sess) + return sess, err +} diff --git a/providers/livechat/session_test.go b/providers/livechat/session_test.go new file mode 100644 index 000000000..702e1b7e6 --- /dev/null +++ b/providers/livechat/session_test.go @@ -0,0 +1,46 @@ +package livechat + +import ( + "github.com/markbates/goth" + "github.com/stretchr/testify/assert" + "testing" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &Session{} + + a.Equal(s.String(), s.Marshal()) +} From 7af71014a0422b11d285a93ad131ff46c4c006c6 Mon Sep 17 00:00:00 2001 From: zdunecki Date: Thu, 4 Mar 2021 16:47:29 +0100 Subject: [PATCH 2/3] go fmt --- providers/livechat/livechat.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/providers/livechat/livechat.go b/providers/livechat/livechat.go index 5d9c4fe0d..c99bf06ee 100644 --- a/providers/livechat/livechat.go +++ b/providers/livechat/livechat.go @@ -170,7 +170,7 @@ func FetchAccount(c *http.Client, accessToken string) (*Account, error) { return account, nil } -func setGothUser(a *Account, user *goth.User) { +func setGothUser(a *Account, user *goth.User) { user.UserID = a.ID user.Name = a.Name user.FirstName, user.LastName = splitName(a.Name) From c51eb60fc7043143e73428bd2c8676acf754c006 Mon Sep 17 00:00:00 2001 From: Patryk Zdunowski Date: Fri, 5 Mar 2021 07:18:03 +0100 Subject: [PATCH 3/3] remove empty line Co-authored-by: techknowlogick --- examples/main.go | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/main.go b/examples/main.go index 05b9b33bb..aa7c6c035 100644 --- a/examples/main.go +++ b/examples/main.go @@ -86,7 +86,6 @@ func main() { linkedin.New(os.Getenv("LINKEDIN_KEY"), os.Getenv("LINKEDIN_SECRET"), "http://localhost:3000/auth/linkedin/callback"), line.New(os.Getenv("LINE_KEY"), os.Getenv("LINE_SECRET"), "http://localhost:3000/auth/line/callback", "profile", "openid", "email"), livechat.New(os.Getenv("LIVECHAT_KEY"), os.Getenv("LIVECHAT_SECRET"), "http://localhost:3000/auth/livechat/callback", livechat.WithConsent()), - lastfm.New(os.Getenv("LASTFM_KEY"), os.Getenv("LASTFM_SECRET"), "http://localhost:3000/auth/lastfm/callback"), twitch.New(os.Getenv("TWITCH_KEY"), os.Getenv("TWITCH_SECRET"), "http://localhost:3000/auth/twitch/callback"), dropbox.New(os.Getenv("DROPBOX_KEY"), os.Getenv("DROPBOX_SECRET"), "http://localhost:3000/auth/dropbox/callback"),