diff --git a/examples/main.go b/examples/main.go index 8fcc28f90..93019ac6c 100644 --- a/examples/main.go +++ b/examples/main.go @@ -191,6 +191,7 @@ func main() { m["battlenet"] = "Battlenet" m["paypal"] = "Paypal" m["twitter"] = "Twitter" + m["twitterv2"] = "Twitter" m["salesforce"] = "Salesforce" m["typetalk"] = "Typetalk" m["slack"] = "Slack" diff --git a/providers/twitterv2/session.go b/providers/twitterv2/session.go new file mode 100644 index 000000000..ef298dde7 --- /dev/null +++ b/providers/twitterv2/session.go @@ -0,0 +1,54 @@ +package twitterv2 + +import ( + "encoding/json" + "errors" + "strings" + + "github.com/markbates/goth" + "github.com/mrjones/oauth" +) + +// Session stores data during the auth process with Twitter. +type Session struct { + AuthURL string + AccessToken *oauth.AccessToken + RequestToken *oauth.RequestToken +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Twitter provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Twitter 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) + accessToken, err := p.consumer.AuthorizeToken(s.RequestToken, params.Get("oauth_verifier")) + if err != nil { + return "", err + } + + s.AccessToken = accessToken + return accessToken.Token, 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/twitterv2/session_test.go b/providers/twitterv2/session_test.go new file mode 100644 index 000000000..9ef101a5c --- /dev/null +++ b/providers/twitterv2/session_test.go @@ -0,0 +1,48 @@ +package twitterv2_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/twitterv2" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &twitterv2.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &twitterv2.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 := &twitterv2.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":null,"RequestToken":null}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &twitterv2.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/twitterv2/twitterv2.go b/providers/twitterv2/twitterv2.go new file mode 100644 index 000000000..4e2103fbc --- /dev/null +++ b/providers/twitterv2/twitterv2.go @@ -0,0 +1,173 @@ +// Package twitterv2 implements the OAuth protocol for authenticating users through Twitter. +// This package can be used as a reference implementation of an OAuth provider for Goth. +package twitterv2 + +import ( + "bytes" + "encoding/json" + "errors" + "io/ioutil" + "net/http" + + "fmt" + + "github.com/markbates/goth" + "github.com/mrjones/oauth" + "golang.org/x/oauth2" +) + +var ( + requestURL = "https://api.twitter.com/oauth/request_token" + authorizeURL = "https://api.twitter.com/oauth/authorize" + authenticateURL = "https://api.twitter.com/oauth/authenticate" + tokenURL = "https://api.twitter.com/oauth/access_token" + endpointProfile = "https://api.twitter.com/2/users/me" +) + +// New creates a new Twitter provider, and sets up important connection details. +// You should always call `twitter.New` to get a new Provider. Never try to create +// one manually. +// +// If you'd like to use authenticate instead of authorize, use NewAuthenticate instead. +func New(clientKey, secret, callbackURL string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "twitterv2", + } + p.consumer = newConsumer(p, authorizeURL) + return p +} + +// NewAuthenticate is the almost same as New. +// NewAuthenticate uses the authenticate URL instead of the authorize URL. +func NewAuthenticate(clientKey, secret, callbackURL string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "twitterv2", + } + p.consumer = newConsumer(p, authenticateURL) + return p +} + +// Provider is the implementation of `goth.Provider` for accessing Twitter. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + debug bool + consumer *oauth.Consumer + 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 +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug sets the logging of the OAuth client to verbose. +func (p *Provider) Debug(debug bool) { + p.debug = debug +} + +// BeginAuth asks Twitter for an authentication end-point and a request token for a session. +// Twitter does not support the "state" variable. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + requestToken, url, err := p.consumer.GetRequestTokenAndUrl(p.CallbackURL) + session := &Session{ + AuthURL: url, + RequestToken: requestToken, + } + return session, err +} + +// FetchUser will go to Twitter and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + Provider: p.Name(), + } + + if sess.AccessToken == nil { + // 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.consumer.Get( + endpointProfile, + map[string]string{"user.fields": ""}, + sess.AccessToken) + if err != nil { + return user, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + bits, err := ioutil.ReadAll(response.Body) + if err != nil { + return user, err + } + + userInfo := struct { + Data map[string]interface{} `json:"data"` + }{} + + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&userInfo) + if err != nil { + return user, err + } + + user.RawData = userInfo.Data + user.Name = user.RawData["name"].(string) + user.NickName = user.RawData["username"].(string) + if user.RawData["email"] != nil { + user.Email = user.RawData["email"].(string) + } + user.Description = user.RawData["description"].(string) + user.AvatarURL = user.RawData["profile_image_url"].(string) + user.UserID = user.RawData["id"].(string) + user.Location = user.RawData["location"].(string) + user.AccessToken = sess.AccessToken.Token + user.AccessTokenSecret = sess.AccessToken.Secret + return user, err +} + +func newConsumer(provider *Provider, authURL string) *oauth.Consumer { + c := oauth.NewConsumer( + provider.ClientKey, + provider.Secret, + oauth.ServiceProvider{ + RequestTokenUrl: requestURL, + AuthorizeTokenUrl: authURL, + AccessTokenUrl: tokenURL, + }) + + c.Debug(provider.debug) + return c +} + +//RefreshToken refresh token is not provided by twitter +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + return nil, errors.New("Refresh token is not provided by twitter") +} + +//RefreshTokenAvailable refresh token is not provided by twitter +func (p *Provider) RefreshTokenAvailable() bool { + return false +} diff --git a/providers/twitterv2/twitterv2_test.go b/providers/twitterv2/twitterv2_test.go new file mode 100644 index 000000000..c70cbd305 --- /dev/null +++ b/providers/twitterv2/twitterv2_test.go @@ -0,0 +1,122 @@ +package twitterv2 + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/gorilla/pat" + "github.com/markbates/goth" + "github.com/mrjones/oauth" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := twitterProvider() + a.Equal(provider.ClientKey, os.Getenv("TWITTER_KEY")) + a.Equal(provider.Secret, os.Getenv("TWITTER_SECRET")) + a.Equal(provider.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + + a.Implements((*goth.Provider)(nil), twitterProvider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := twitterProvider() + session, err := provider.BeginAuth("state") + s := session.(*Session) + a.NoError(err) + a.Contains(s.AuthURL, "authorize?oauth_token=TOKEN") + a.Equal("TOKEN", s.RequestToken.Token) + a.Equal("SECRET", s.RequestToken.Secret) + + provider = twitterProviderAuthenticate() + session, err = provider.BeginAuth("state") + s = session.(*Session) + a.NoError(err) + a.Contains(s.AuthURL, "authenticate?oauth_token=TOKEN") + a.Equal("TOKEN", s.RequestToken.Token) + a.Equal("SECRET", s.RequestToken.Secret) +} + +func Test_FetchUser(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := twitterProvider() + session := Session{AccessToken: &oauth.AccessToken{Token: "TOKEN", Secret: "SECRET"}} + + user, err := provider.FetchUser(&session) + a.NoError(err) + + a.Equal("Homer", user.Name) + a.Equal("duffman", user.NickName) + a.Equal("Duff rules!!", user.Description) + a.Equal("http://example.com/image.jpg", user.AvatarURL) + a.Equal("1234", user.UserID) + a.Equal("Springfield", user.Location) + a.Equal("TOKEN", user.AccessToken) + a.Equal("duffman@springfield.com", user.Email) +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := twitterProvider() + + s, err := provider.UnmarshalSession(`{"AuthURL":"http://com/auth_url","AccessToken":{"Token":"1234567890","Secret":"secret!!","AdditionalData":{}},"RequestToken":{"Token":"0987654321","Secret":"!!secret"}}`) + a.NoError(err) + session := s.(*Session) + a.Equal(session.AuthURL, "http://com/auth_url") + a.Equal(session.AccessToken.Token, "1234567890") + a.Equal(session.AccessToken.Secret, "secret!!") + a.Equal(session.RequestToken.Token, "0987654321") + a.Equal(session.RequestToken.Secret, "!!secret") +} + +func twitterProvider() *Provider { + return New(os.Getenv("TWITTER_KEY"), os.Getenv("TWITTER_SECRET"), "/foo") +} + +func twitterProviderAuthenticate() *Provider { + return NewAuthenticate(os.Getenv("TWITTER_KEY"), os.Getenv("TWITTER_SECRET"), "/foo") +} + +func init() { + p := pat.New() + p.Get("/oauth/request_token", func(res http.ResponseWriter, req *http.Request) { + fmt.Fprint(res, "oauth_token=TOKEN&oauth_token_secret=SECRET") + }) + p.Get("/2/users/me", func(res http.ResponseWriter, req *http.Request) { + data := map[string]interface{}{ + "data": map[string]string{ + "name": "Homer", + "username": "duffman", + "description": "Duff rules!!", + "profile_image_url": "http://example.com/image.jpg", + "id": "1234", + "location": "Springfield", + "email": "duffman@springfield.com", + }, + } + json.NewEncoder(res).Encode(&data) + }) + ts := httptest.NewServer(p) + + requestURL = ts.URL + "/oauth/request_token" + endpointProfile = ts.URL + "/2/users/me" +}