From 819a7c4738f8781cb347a7926b3c3d92c36e1ac7 Mon Sep 17 00:00:00 2001 From: Ben Tranter Date: Fri, 12 Oct 2018 19:16:15 -0400 Subject: [PATCH 1/2] Add simple Google provider Adds a simple Google provider. Closes #214 Adds a new "Google" provider. This essentially clones the existing `gplus` provider, with a few differences: * It will, by default, only send the `email` scope, since `profile` is a Google+ specific scope, and `openid` is a bit excessive for a default scope. * It uses the `oauth2.Endpoint` from `golang.org/x/oauth2/google` so we always keep up to date with Go's OAuth2 package. * It simplifies the logic that populates a `goth.User` from the JSON returned from Google's server. It also includes a few housekeeping items: 1. Marks the Google+ provider as deprecated in the README. 2. Updates the `go.mod` file. 3. Checks in the `go.sum` file. I know not everyone does this, but for now it seems to be community consensus that it's a good idea to check it in, so I figure we'd do that. --- README.md | 3 +- examples/main.go | 5 +- go.mod | 1 + go.sum | 22 ++++ providers/google/google.go | 171 +++++++++++++++++++++++++++++++ providers/google/google_test.go | 78 ++++++++++++++ providers/google/session.go | 61 +++++++++++ providers/google/session_test.go | 48 +++++++++ 8 files changed, 387 insertions(+), 2 deletions(-) create mode 100644 go.sum create mode 100644 providers/google/google.go create mode 100644 providers/google/google_test.go create mode 100644 providers/google/session.go create mode 100644 providers/google/session_test.go diff --git a/README.md b/README.md index c45b8a8d4..027c90219 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,8 @@ $ go get github.com/markbates/goth * Fitbit * GitHub * Gitlab -* Google+ +* Google +* Google+ (deprecated) * Heroku * InfluxCloud * Instagram diff --git a/examples/main.go b/examples/main.go index e521abd60..82e0a3d86 100644 --- a/examples/main.go +++ b/examples/main.go @@ -29,6 +29,7 @@ import ( "github.com/markbates/goth/providers/fitbit" "github.com/markbates/goth/providers/github" "github.com/markbates/goth/providers/gitlab" + "github.com/markbates/goth/providers/google" "github.com/markbates/goth/providers/gplus" "github.com/markbates/goth/providers/heroku" "github.com/markbates/goth/providers/instagram" @@ -65,6 +66,7 @@ func main() { facebook.New(os.Getenv("FACEBOOK_KEY"), os.Getenv("FACEBOOK_SECRET"), "http://localhost:3000/auth/facebook/callback"), fitbit.New(os.Getenv("FITBIT_KEY"), os.Getenv("FITBIT_SECRET"), "http://localhost:3000/auth/fitbit/callback"), + google.New(os.Getenv("GOOGLE_KEY"), os.Getenv("GOOGLE_SECRET"), "http://localhost:3000/auth/google/callback"), gplus.New(os.Getenv("GPLUS_KEY"), os.Getenv("GPLUS_SECRET"), "http://localhost:3000/auth/gplus/callback"), github.New(os.Getenv("GITHUB_KEY"), os.Getenv("GITHUB_SECRET"), "http://localhost:3000/auth/github/callback"), spotify.New(os.Getenv("SPOTIFY_KEY"), os.Getenv("SPOTIFY_SECRET"), "http://localhost:3000/auth/spotify/callback"), @@ -134,6 +136,8 @@ func main() { m["fitbit"] = "Fitbit" m["github"] = "Github" m["gitlab"] = "Gitlab" + m["google"] = "Google" + m["gplus"] = "Google Plus" m["soundcloud"] = "SoundCloud" m["spotify"] = "Spotify" m["steam"] = "Steam" @@ -143,7 +147,6 @@ func main() { m["wepay"] = "Wepay" m["yahoo"] = "Yahoo" m["yammer"] = "Yammer" - m["gplus"] = "Google Plus" m["heroku"] = "Heroku" m["instagram"] = "Instagram" m["intercom"] = "Intercom" diff --git a/go.mod b/go.mod index 666766e62..377cc479e 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,7 @@ module github.com/markbates/goth require ( + cloud.google.com/go v0.30.0 github.com/gorilla/mux v1.6.2 github.com/gorilla/pat v0.0.0-20180118222023-199c85a7f6d1 github.com/gorilla/sessions v1.1.1 diff --git a/go.sum b/go.sum new file mode 100644 index 000000000..5235d12d0 --- /dev/null +++ b/go.sum @@ -0,0 +1,22 @@ +cloud.google.com/go v0.30.0 h1:xKvyLgk56d0nksWq49J0UyGEeUIicTl4+UBiX1NPX9g= +cloud.google.com/go v0.30.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go/compute v0.0.0-20181010175407-5f0ffe772937/go.mod h1:G7mAYYxgmS0lVkHyy2hEOLQCFB0DlQFTMLWggykrydY= +cloud.google.com/go/compute/metadata v0.0.0-20181010175407-5f0ffe772937/go.mod h1:G7mAYYxgmS0lVkHyy2hEOLQCFB0DlQFTMLWggykrydY= +github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= +github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/mux v1.6.2 h1:Pgr17XVTNXAk3q/r4CpKzC5xBM/qW1uVLV+IhRZpIIk= +github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/pat v0.0.0-20180118222023-199c85a7f6d1 h1:LqbZZ9sNMWVjeXS4NN5oVvhMjDyLhmA1LG86oSo+IqY= +github.com/gorilla/pat v0.0.0-20180118222023-199c85a7f6d1/go.mod h1:YeAe0gNeiNT5hoiZRI4yiOky6jVdNvfO2N6Kav/HmxY= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.1.1 h1:YMDmfaK68mUixINzY/XjscuJ47uXFWSSHzFbBQM0PrE= +github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w= +github.com/markbates/going v1.0.0 h1:DQw0ZP7NbNlFGcKbcE/IVSOAFzScxRtLpd0rLMzLhq0= +github.com/markbates/going v1.0.0/go.mod h1:I6mnB4BPnEeqo85ynXIx1ZFLLbtiLHNXVgWeFO9OGOA= +github.com/mrjones/oauth v0.0.0-20180629183705-f4e24b6d100c h1:3wkDRdxK92dF+c1ke2dtj7ZzemFWBHB9plnJOtlwdFA= +github.com/mrjones/oauth v0.0.0-20180629183705-f4e24b6d100c/go.mod h1:skjdDftzkFALcuGzYSklqYd8gvat6F1gZJ4YPVbkZpM= +golang.org/x/net v0.0.0-20180706051357-32a936f46389 h1:U+zCn5sqaq+q4hrnMrz9sgrW1yatwEOUgYkGt3u9ZOU= +golang.org/x/net v0.0.0-20180706051357-32a936f46389/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/oauth2 v0.0.0-20180620175406-ef147856a6dd h1:QQhib242ErYDSMitlBm8V7wYCm/1a25hV8qMadIKLPA= +golang.org/x/oauth2 v0.0.0-20180620175406-ef147856a6dd/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= diff --git a/providers/google/google.go b/providers/google/google.go new file mode 100644 index 000000000..425a1fff2 --- /dev/null +++ b/providers/google/google.go @@ -0,0 +1,171 @@ +// Package google implements the OAuth2 protocol for authenticating users +// through Google. +package google + +import ( + "encoding/json" + "net/http" + "net/url" + "strings" + + "fmt" + + "github.com/markbates/goth" + "golang.org/x/oauth2" + goog "golang.org/x/oauth2/google" +) + +const endpointProfile string = "https://www.googleapis.com/oauth2/v2/userinfo" + +// New creates a new Google+ provider, and sets up important connection details. +// You should always call `google.New` to get a new Provider. Never try to create +// one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "google", + } + p.config = newConfig(p, scopes) + return p +} + +// Provider is the implementation of `goth.Provider` for accessing Google+. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + prompt oauth2.AuthCodeOption + providerName string +} + +// 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 +} + +// Client returns an HTTP client to be used in all fetch operations. +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the google package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Google for an authentication endpoint. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + var opts []oauth2.AuthCodeOption + if p.prompt != nil { + opts = append(opts, p.prompt) + } + url := p.config.AuthCodeURL(state, opts...) + session := &Session{ + AuthURL: url, + } + return session, nil +} + +type googleUser struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + FirstName string `json:"given_name"` + LastName string `json:"family_name"` + Link string `json:"link"` + Picture string `json:"picture"` +} + +// FetchUser will go to Google and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + RefreshToken: sess.RefreshToken, + ExpiresAt: sess.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) + } + + response, err := p.Client().Get(endpointProfile + "?access_token=" + url.QueryEscape(sess.AccessToken)) + if err != nil { + return user, err + } + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + u := &googleUser{} + if err := json.NewDecoder(response.Body).Decode(u); err != nil { + return user, err + } + defer response.Body.Close() + + // Extract the user data we got from Google into our goth.User. + user.Name = u.Name + user.FirstName = u.FirstName + user.LastName = u.LastName + user.NickName = u.Name + user.Email = u.Email + user.AvatarURL = u.Picture + user.UserID = u.ID + + return user, nil +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: goog.Endpoint, + Scopes: []string{}, + } + + if len(scopes) > 0 { + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + } else { + c.Scopes = []string{"email"} + } + return c +} + +//RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +//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(goth.ContextForClient(p.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} + +// SetPrompt sets the prompt values for the google OAuth call. Use this to +// force users to choose and account every time by passing "select_account", +// for example. +// See https://developers.google.com/identity/protocols/OpenIDConnect#authenticationuriparameters +func (p *Provider) SetPrompt(prompt ...string) { + if len(prompt) == 0 { + return + } + p.prompt = oauth2.SetAuthURLParam("prompt", strings.Join(prompt, " ")) +} diff --git a/providers/google/google_test.go b/providers/google/google_test.go new file mode 100644 index 000000000..c02460fc1 --- /dev/null +++ b/providers/google/google_test.go @@ -0,0 +1,78 @@ +package google_test + +import ( + "fmt" + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/google" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := googleProvider() + a.Equal(provider.ClientKey, os.Getenv("GOOGLE_KEY")) + a.Equal(provider.Secret, os.Getenv("GOOGLE_SECRET")) + a.Equal(provider.CallbackURL, "/foo") +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := googleProvider() + session, err := provider.BeginAuth("test_state") + s := session.(*google.Session) + a.NoError(err) + a.Contains(s.AuthURL, "accounts.google.com/o/oauth2/auth") + a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("GOOGLE_KEY"))) + a.Contains(s.AuthURL, "state=test_state") + a.Contains(s.AuthURL, "scope=email") +} + +func Test_BeginAuthWithPrompt(t *testing.T) { + // This exists because there was a panic caused by the oauth2 package when + // the AuthCodeOption passed was nil. This test uses it, Test_BeginAuth does + // not, to ensure both cases are covered. + t.Parallel() + a := assert.New(t) + + provider := googleProvider() + provider.SetPrompt("test", "prompts") + session, err := provider.BeginAuth("test_state") + s := session.(*google.Session) + a.NoError(err) + a.Contains(s.AuthURL, "accounts.google.com/o/oauth2/auth") + a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("GOOGLE_KEY"))) + a.Contains(s.AuthURL, "state=test_state") + a.Contains(s.AuthURL, "scope=email") + a.Contains(s.AuthURL, "prompt=test+prompts") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + + a.Implements((*goth.Provider)(nil), googleProvider()) +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := googleProvider() + + s, err := provider.UnmarshalSession(`{"AuthURL":"https://accounts.google.com/o/oauth2/auth","AccessToken":"1234567890"}`) + a.NoError(err) + session := s.(*google.Session) + a.Equal(session.AuthURL, "https://accounts.google.com/o/oauth2/auth") + a.Equal(session.AccessToken, "1234567890") +} + +func googleProvider() *google.Provider { + return google.New(os.Getenv("GOOGLE_KEY"), os.Getenv("GOOGEL_SECRET"), "/foo") +} diff --git a/providers/google/session.go b/providers/google/session.go new file mode 100644 index 000000000..866620302 --- /dev/null +++ b/providers/google/session.go @@ -0,0 +1,61 @@ +package google + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Google+. +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 Google+ provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Google+ 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(goth.ContextForClient(p.Client()), 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/google/session_test.go b/providers/google/session_test.go new file mode 100644 index 000000000..e68b3a3ad --- /dev/null +++ b/providers/google/session_test.go @@ -0,0 +1,48 @@ +package google_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/google" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &google.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &google.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 := &google.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 := &google.Session{} + + a.Equal(s.String(), s.Marshal()) +} From d18d22ec8eebc0b6d90837945fa1359ad1ec2812 Mon Sep 17 00:00:00 2001 From: Ben Tranter Date: Fri, 12 Oct 2018 19:24:32 -0400 Subject: [PATCH 2/2] Remove references to Google+ in Google provider --- providers/google/google.go | 4 ++-- providers/google/session.go | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/providers/google/google.go b/providers/google/google.go index 425a1fff2..dcaec35e7 100644 --- a/providers/google/google.go +++ b/providers/google/google.go @@ -17,7 +17,7 @@ import ( const endpointProfile string = "https://www.googleapis.com/oauth2/v2/userinfo" -// New creates a new Google+ provider, and sets up important connection details. +// New creates a new Google provider, and sets up important connection details. // You should always call `google.New` to get a new Provider. Never try to create // one manually. func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { @@ -31,7 +31,7 @@ func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { return p } -// Provider is the implementation of `goth.Provider` for accessing Google+. +// Provider is the implementation of `goth.Provider` for accessing Google. type Provider struct { ClientKey string Secret string diff --git a/providers/google/session.go b/providers/google/session.go index 866620302..47df1601b 100644 --- a/providers/google/session.go +++ b/providers/google/session.go @@ -9,7 +9,7 @@ import ( "github.com/markbates/goth" ) -// Session stores data during the auth process with Google+. +// Session stores data during the auth process with Google. type Session struct { AuthURL string AccessToken string @@ -17,7 +17,7 @@ type Session struct { ExpiresAt time.Time } -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Google+ provider. +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Google provider. func (s Session) GetAuthURL() (string, error) { if s.AuthURL == "" { return "", errors.New(goth.NoAuthUrlErrorMessage) @@ -25,7 +25,7 @@ func (s Session) GetAuthURL() (string, error) { return s.AuthURL, nil } -// Authorize the session with Google+ and return the access token to be stored for future use. +// Authorize the session with Google 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(goth.ContextForClient(p.Client()), params.Get("code"))