From 5709a55c7aa6bcadccbc60d91b522fe27caaf1d6 Mon Sep 17 00:00:00 2001 From: Mark Bates Date: Tue, 14 Oct 2014 16:36:47 -0400 Subject: [PATCH] Initial commit --- .gitignore | 26 +++++ .travis.yml | 7 ++ LICENSE.txt | 22 ++++ README.md | 36 +++++++ doc.go | 10 ++ examples/main.go | 56 +++++++++++ gothic/gothic.go | 149 ++++++++++++++++++++++++++++ gothic/gothic_test.go | 100 +++++++++++++++++++ provider.go | 46 +++++++++ provider_test.go | 35 +++++++ providers/facebook/facebook.go | 136 +++++++++++++++++++++++++ providers/facebook/facebook_test.go | 56 +++++++++++ providers/facebook/session.go | 46 +++++++++ providers/facebook/session_test.go | 48 +++++++++ providers/faux/README.md | 3 + providers/faux/faux.go | 65 ++++++++++++ providers/twitter/session.go | 45 +++++++++ providers/twitter/session_test.go | 48 +++++++++ providers/twitter/twitter.go | 113 +++++++++++++++++++++ providers/twitter/twitter_test.go | 60 +++++++++++ session.go | 21 ++++ user.go | 15 +++ user_test.go | 1 + 23 files changed, 1144 insertions(+) create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 doc.go create mode 100644 examples/main.go create mode 100644 gothic/gothic.go create mode 100644 gothic/gothic_test.go create mode 100644 provider.go create mode 100644 provider_test.go create mode 100644 providers/facebook/facebook.go create mode 100644 providers/facebook/facebook_test.go create mode 100644 providers/facebook/session.go create mode 100644 providers/facebook/session_test.go create mode 100644 providers/faux/README.md create mode 100644 providers/faux/faux.go create mode 100644 providers/twitter/session.go create mode 100644 providers/twitter/session_test.go create mode 100644 providers/twitter/twitter.go create mode 100644 providers/twitter/twitter_test.go create mode 100644 session.go create mode 100644 user.go create mode 100644 user_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..d3576127f --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +*.log +.DS_Store +doc +tmp +pkg +*.gem +*.pid +coverage +coverage.data +build/* +*.pbxuser +*.mode1v3 +.svn +profile +.console_history +.sass-cache/* +.rake_tasks~ +*.log.lck +solr/ +.jhw-cache/ +jhw.* +*.sublime* +node_modules/ +dist/ +generated/ +.vendor/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000..57bd4a76e --- /dev/null +++ b/.travis.yml @@ -0,0 +1,7 @@ +language: go + +go: + - 1.1 + - 1.2 + - 1.3 + - tip diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 000000000..f8e6d5b27 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,22 @@ +Copyright (c) 2014 Mark Bates + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 000000000..fb7211588 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# Goth + +Package goth provides a simple, clean, and idiomatic way to write authentication +packages for Go web applications. + +Unlike other similar packages, Goth, let's you write OAuth, OAuth2, or any other +protocol providers, as long as they implement the `Provider` and `Session` interfaces. + +This package was inspired by [https://github.com/intridea/omniauth](https://github.com/intridea/omniauth). + +## Installation + +```text +$ go get github.com/markbates/goth +``` + +## Examples + +See the [examples](examples) folder for a working application that let's users authenticate +through Twitter or Facebook. + +## Issues + +Issues always stand a significantly better chance of getting fixed if the are accompanied by a +pull request. + +## Contributing + +Would I love to see more providers? Certainly! Would you love to contribute one? Hopefully, yes! + +1. Fork it +2. Create your feature branch (git checkout -b my-new-feature) +3. Write Tests! +4. Commit your changes (git commit -am 'Add some feature') +5. Push to the branch (git push origin my-new-feature) +6. Create new Pull Request diff --git a/doc.go b/doc.go new file mode 100644 index 000000000..1c3bc1b9b --- /dev/null +++ b/doc.go @@ -0,0 +1,10 @@ +/* +Package goth provides a simple, clean, and idiomatic way to write authentication +packages for Go web applications. + +This package was inspired by https://github.com/intridea/omniauth. + +See the examples folder for a working application that let's users authenticate +through Twitter or Facebook. +*/ +package goth diff --git a/examples/main.go b/examples/main.go new file mode 100644 index 000000000..59f73720e --- /dev/null +++ b/examples/main.go @@ -0,0 +1,56 @@ +package main + +import ( + "fmt" + "html/template" + "net/http" + "os" + + "github.com/gorilla/context" + "github.com/gorilla/pat" + "github.com/markbates/goth" + "github.com/markbates/goth/gothic" + "github.com/markbates/goth/providers/facebook" + "github.com/markbates/goth/providers/twitter" +) + +func main() { + goth.UseProviders( + twitter.New(os.Getenv("TWITTER_KEY"), os.Getenv("TWITTER_SECRET"), "http://localhost:3000/auth/twitter/callback"), + facebook.New(os.Getenv("FACEBOOK_KEY"), os.Getenv("FACEBOOK_SECRET"), "http://localhost:3000/auth/facebook/callback"), + ) + + p := pat.New() + p.Get("/auth/{provider}/callback", func(res http.ResponseWriter, req *http.Request) { + user, err := gothic.CompleteUserAuth(res, req) + if err != nil { + fmt.Fprintln(res, err) + return + } + t, _ := template.New("foo").Parse(userTemplate) + t.Execute(res, user) + }) + + p.Get("/auth/{provider}", gothic.BeginAuthHandler) + p.Get("/", func(res http.ResponseWriter, req *http.Request) { + t, _ := template.New("foo").Parse(indexTemplate) + t.Execute(res, nil) + }) + http.ListenAndServe(":3000", context.ClearHandler(p)) +} + +var indexTemplate = ` +

Log in with Twitter

+

Log in with Facebook

+` + +var userTemplate = ` +

Name: {{.Name}}

+

Email: {{.Email}}

+

NickName: {{.NickName}}

+

Location: {{.Location}}

+

AvatarURL: {{.AvatarURL}}

+

Description: {{.Description}}

+

UserID: {{.UserID}}

+

AccessToken: {{.AccessToken}}

+` diff --git a/gothic/gothic.go b/gothic/gothic.go new file mode 100644 index 000000000..f19770e4d --- /dev/null +++ b/gothic/gothic.go @@ -0,0 +1,149 @@ +/* +Package gothic wraps common behaviour when using Goth. This makes it quick, and easy, to get up +and running with Goth. Of course, if you want complete control over how things flow, in regards +to the authentication process, feel free and use Goth directly. + +See https://github.com/markbates/goth/examples/main.go to see this in action. +*/ +package gothic + +import ( + "errors" + "fmt" + "net/http" + + "github.com/gorilla/context" + "github.com/gorilla/sessions" + "github.com/markbates/goth" +) + +// SessionName is the key used to access the session store. +const SessionName = "_gothic_session" + +// AppKey should be replaced by applications using gothic. +var AppKey = "XDZZYmriq8pJ5k8OKqdDuUFym2e7Im5O1MzdyapfotOnrqQ7ZEdTN9AA7K6aPieC" + +// Store can/should be set by applications using gothic. The default is a cookie store. +var Store sessions.Store + +func init() { + if Store == nil { + Store = sessions.NewCookieStore([]byte(AppKey)) + } +} + +/* +BeginAuthHandler is a convienence handler for starting the authentication process. +It expects to be able to get the name of the provider from the query parameters +as either "provider" or ":provider". + +BeginAuthHandler will redirect the user to the appropriate authentication end-point +for the requested provider. + +See https://github.com/markbates/goth/examples/main.go to see this in action. +*/ +func BeginAuthHandler(res http.ResponseWriter, req *http.Request) { + url, err := GetAuthURL(res, req) + if err != nil { + res.WriteHeader(http.StatusBadRequest) + fmt.Fprintln(res, err) + return + } + + http.Redirect(res, req, url, http.StatusTemporaryRedirect) +} + +/* +GetAuthURL starts the authentication process with the requested provided. +It will return a URL that should be used to send users to. + +It expects to be able to get the name of the provider from the query parameters +as either "provider" or ":provider". + +I would recommend using the BeginAuthHandler instead of doing all of these steps +yourself, but that's entirely up to you. +*/ +func GetAuthURL(res http.ResponseWriter, req *http.Request) (string, error) { + defer context.Clear(req) + + providerName, err := getProviderName(req) + if err != nil { + return "", err + } + + provider, err := goth.GetProvider(providerName) + if err != nil { + return "", err + } + sess, err := provider.BeginAuth() + if err != nil { + return "", err + } + + url, err := sess.GetAuthURL() + if err != nil { + return "", err + } + + session, _ := Store.Get(req, SessionName) + session.Values[SessionName] = sess.Marshal() + err = session.Save(req, res) + if err != nil { + return "", err + } + + return url, err +} + +/* +CompleteUserAuth does what it says on the tin. It completes the authentication +process and fetches all of the basic information about the user from the provider. + +It expects to be able to get the name of the provider from the query parameters +as either "provider" or ":provider". + +See https://github.com/markbates/goth/examples/main.go to see this in action. +*/ +func CompleteUserAuth(res http.ResponseWriter, req *http.Request) (goth.User, error) { + defer context.Clear(req) + + providerName, err := getProviderName(req) + if err != nil { + return goth.User{}, err + } + + provider, err := goth.GetProvider(providerName) + if err != nil { + return goth.User{}, err + } + + session, _ := Store.Get(req, SessionName) + + if session.Values[SessionName] == nil { + return goth.User{}, errors.New("could not find a matching session for this request") + } + + sess, err := provider.UnmarshalSession(session.Values[SessionName].(string)) + if err != nil { + return goth.User{}, err + } + + _, err = sess.Authorize(provider, req.URL.Query()) + + if err != nil { + return goth.User{}, err + } + + return provider.FetchUser(sess) +} + +func getProviderName(req *http.Request) (string, error) { + provider := req.URL.Query().Get("provider") + if provider == "" { + provider = req.URL.Query().Get(":provider") + } + if provider == "" { + return provider, errors.New("you must select a provider") + } + return provider, nil +} diff --git a/gothic/gothic_test.go b/gothic/gothic_test.go new file mode 100644 index 000000000..30ed5f339 --- /dev/null +++ b/gothic/gothic_test.go @@ -0,0 +1,100 @@ +package gothic_test + +import ( + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/gorilla/sessions" + "github.com/markbates/goth" + . "github.com/markbates/goth/gothic" + "github.com/markbates/goth/providers/faux" + "github.com/stretchr/testify/assert" +) + +type ProviderStore struct { + Store map[*http.Request]*sessions.Session +} + +func NewProviderStore() *ProviderStore { + return &ProviderStore{map[*http.Request]*sessions.Session{}} +} + +func (self ProviderStore) Get(r *http.Request, name string) (*sessions.Session, error) { + s := self.Store[r] + if s == nil { + s, err := self.New(r, name) + return s, err + } + return s, nil +} + +func (self ProviderStore) New(r *http.Request, name string) (*sessions.Session, error) { + s := sessions.NewSession(self, name) + self.Store[r] = s + return s, nil +} + +func (self ProviderStore) Save(r *http.Request, w http.ResponseWriter, s *sessions.Session) error { + self.Store[r] = s + return nil +} + +func init() { + Store = sessions.NewFilesystemStore(os.TempDir(), []byte(AppKey)) + goth.UseProviders(&faux.Provider{}) +} + +func Test_BeginAuthHandler(t *testing.T) { + t.Parallel() + + a := assert.New(t) + + res := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/auth?provider=faux", nil) + a.NoError(err) + + BeginAuthHandler(res, req) + + a.Equal(http.StatusTemporaryRedirect, res.Code) + a.Contains(res.Body.String(), `Temporary Redirect`) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + + a := assert.New(t) + + res := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/auth?provider=faux", nil) + a.NoError(err) + + url, err := GetAuthURL(res, req) + + a.NoError(err) + + a.Equal("http://example.com/auth/", url) +} + +func Test_CompleteUserAuth(t *testing.T) { + t.Parallel() + + a := assert.New(t) + + res := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/auth/callback?provider=faux", nil) + a.NoError(err) + + sess := faux.Session{Name: "Homer Simpson", Email: "homer@example.com"} + session, _ := Store.Get(req, SessionName) + session.Values[SessionName] = sess.Marshal() + err = session.Save(req, res) + a.NoError(err) + + user, err := CompleteUserAuth(res, req) + a.NoError(err) + + a.Equal(user.Name, "Homer Simpson") + a.Equal(user.Email, "homer@example.com") +} diff --git a/provider.go b/provider.go new file mode 100644 index 000000000..c328497ef --- /dev/null +++ b/provider.go @@ -0,0 +1,46 @@ +package goth + +import "fmt" + +// Provider needs to be implemented for each 3rd party authentication provider +// e.g. Facebook, Twitter, etc... +type Provider interface { + Name() string + BeginAuth() (Session, error) + UnmarshalSession(string) (Session, error) + FetchUser(Session) (User, error) + Debug(bool) +} + +// Providers is list of known/available providers. +type Providers map[string]Provider + +var providers = Providers{} + +// UseProviders sets a list of available providers for use with Goth. +func UseProviders(viders ...Provider) { + for _, provider := range viders { + providers[provider.Name()] = provider + } +} + +// GetProviders returns a list of all the providers currently in use. +func GetProviders() Providers { + return providers +} + +// GetProvider returns a previously created provider. If Goth has not +// been told to use the named provider it will return an error. +func GetProvider(name string) (Provider, error) { + provider := providers[name] + if provider == nil { + return nil, fmt.Errorf("no provider for %s exists", name) + } + return provider, nil +} + +// ClearProviders will remove all providers currently in use. +// This is useful, mostly, for testing purposes. +func ClearProviders() { + providers = Providers{} +} diff --git a/provider_test.go b/provider_test.go new file mode 100644 index 000000000..b7acceca2 --- /dev/null +++ b/provider_test.go @@ -0,0 +1,35 @@ +package goth_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/faux" + "github.com/stretchr/testify/assert" +) + +func Test_UseProviders(t *testing.T) { + a := assert.New(t) + + provider := &faux.Provider{} + goth.UseProviders(provider) + a.Equal(len(goth.GetProviders()), 1) + a.Equal(goth.GetProviders()[provider.Name()], provider) + goth.ClearProviders() +} + +func Test_GetProvider(t *testing.T) { + a := assert.New(t) + + provider := &faux.Provider{} + goth.UseProviders(provider) + + p, err := goth.GetProvider(provider.Name()) + a.NoError(err) + a.Equal(p, provider) + + p, err = goth.GetProvider("unknown") + a.Error(err) + a.Equal(err.Error(), "no provider for unknown exists") + goth.ClearProviders() +} diff --git a/providers/facebook/facebook.go b/providers/facebook/facebook.go new file mode 100644 index 000000000..02e05bd45 --- /dev/null +++ b/providers/facebook/facebook.go @@ -0,0 +1,136 @@ +// Package facebook implements the OAuth2 protocol for authenticating users through Facebook. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package facebook + +import ( + "bytes" + "encoding/json" + "io" + "io/ioutil" + "net/http" + "net/url" + "strings" + + "code.google.com/p/goauth2/oauth" + "github.com/markbates/goth" +) + +const ( + authURL string = "https://www.facebook.com/dialog/oauth" + tokenURL string = "https://graph.facebook.com/oauth/access_token" + endpointProfile string = "https://graph.facebook.com/me?fields=email,first_name,last_name,link,bio,id,name,picture,location" +) + +// New creates a new Facebook provider, and sets up important connection details. +// You should always call `facebook.New` to get a new Provider. Never try to create +// one manually. +func New(clientKey, secret, callbackURL string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + } + p.config = newConfig(p) + return p +} + +// Provider is the implementation of `goth.Provider` for accessing Facebook. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + config *oauth.Config +} + +// Name is the name used to retrieve this provider later. +func (self *Provider) Name() string { + return "facebook" +} + +// Debug is a no-op for the facebook package. +func (self *Provider) Debug(debug bool) {} + +// BeginAuth asks Facebook for an authentication end-point. +func (self *Provider) BeginAuth() (goth.Session, error) { + url := self.config.AuthCodeURL("state") + session := &Session{ + AuthURL: url, + } + return session, nil +} + +// FetchUser will go to Facebook and access basic information about the user. +func (self *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{AccessToken: sess.AccessToken} + + response, err := http.Get(endpointProfile + "&access_token=" + url.QueryEscape(sess.AccessToken)) + if err != nil { + return user, err + } + defer response.Body.Close() + + bits, err := ioutil.ReadAll(response.Body) + if err != nil { + return user, err + } + + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) + if err != nil { + return user, err + } + + err = userFromReader(bytes.NewReader(bits), &user) + return user, err +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (self *Provider) UnmarshalSession(data string) (goth.Session, error) { + sess := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(sess) + return sess, err +} + +func userFromReader(reader io.Reader, user *goth.User) error { + u := struct { + ID string `json:"id"` + Email string `json:"email"` + Bio string `json:"bio"` + Name string `json:"name"` + Link string `json:"link"` + Picture struct { + Data struct { + URL string `json:"url"` + } `json:"data"` + } `json:"picture"` + Location struct { + Name string `json:"name"` + } `json:"location"` + }{} + + err := json.NewDecoder(reader).Decode(&u) + if err != nil { + return err + } + + user.Name = u.Name + user.NickName = u.Name + user.Email = u.Email + user.Description = u.Bio + user.AvatarURL = u.Picture.Data.URL + user.UserID = u.ID + user.Location = u.Location.Name + + return err +} + +func newConfig(provider *Provider) *oauth.Config { + c := &oauth.Config{ + ClientId: provider.ClientKey, + ClientSecret: provider.Secret, + AuthURL: authURL, + TokenURL: tokenURL, + RedirectURL: provider.CallbackURL, + } + return c +} diff --git a/providers/facebook/facebook_test.go b/providers/facebook/facebook_test.go new file mode 100644 index 000000000..00e1c0d1e --- /dev/null +++ b/providers/facebook/facebook_test.go @@ -0,0 +1,56 @@ +package facebook_test + +import ( + "fmt" + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/facebook" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := facebookProvider() + a.Equal(provider.ClientKey, os.Getenv("FACEBOOK_KEY")) + a.Equal(provider.Secret, os.Getenv("FACEBOOK_SECRET")) + a.Equal(provider.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + + a.Implements((*goth.Provider)(nil), facebookProvider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := facebookProvider() + session, err := provider.BeginAuth() + s := session.(*facebook.Session) + a.NoError(err) + a.Equal(s.AuthURL, fmt.Sprintf("https://www.facebook.com/dialog/oauth?client_id=%s&redirect_uri=%%2Ffoo&response_type=code&state=state", provider.ClientKey)) +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := facebookProvider() + + s, err := provider.UnmarshalSession(`{"AuthURL":"http://facebook.com/auth_url","AccessToken":"1234567890"}`) + a.NoError(err) + session := s.(*facebook.Session) + a.Equal(session.AuthURL, "http://facebook.com/auth_url") + a.Equal(session.AccessToken, "1234567890") +} + +func facebookProvider() *facebook.Provider { + return facebook.New(os.Getenv("FACEBOOK_KEY"), os.Getenv("FACEBOOK_SECRET"), "/foo") +} diff --git a/providers/facebook/session.go b/providers/facebook/session.go new file mode 100644 index 000000000..3f6da7ec1 --- /dev/null +++ b/providers/facebook/session.go @@ -0,0 +1,46 @@ +package facebook + +import ( + "encoding/json" + "errors" + + "code.google.com/p/goauth2/oauth" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Facebook. +type Session struct { + AuthURL string + AccessToken string +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Facebook provider. +func (self Session) GetAuthURL() (string, error) { + if self.AuthURL == "" { + return "", errors.New("an AuthURL has not be set") + } + return self.AuthURL, nil +} + +// Authorize the session with Facebook and return the access token to be stored for future use. +func (self *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + t := &oauth.Transport{Config: p.config} + token, err := t.Exchange(params.Get("code")) + if err != nil { + return "", err + } + self.AccessToken = token.AccessToken + return token.AccessToken, err +} + +// Marshal the session into a string +func (self Session) Marshal() string { + b, _ := json.Marshal(self) + return string(b) +} + +func (self Session) String() string { + return self.Marshal() +} diff --git a/providers/facebook/session_test.go b/providers/facebook/session_test.go new file mode 100644 index 000000000..87b3b8fbd --- /dev/null +++ b/providers/facebook/session_test.go @@ -0,0 +1,48 @@ +package facebook_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/facebook" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &facebook.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &facebook.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 := &facebook.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":""}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &facebook.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/faux/README.md b/providers/faux/README.md new file mode 100644 index 000000000..6654a6178 --- /dev/null +++ b/providers/faux/README.md @@ -0,0 +1,3 @@ +# FauxProvider + +This provider is merely here to help with testing other parts of these packages. I wouldn't recommend using it. :) diff --git a/providers/faux/faux.go b/providers/faux/faux.go new file mode 100644 index 000000000..1943c8218 --- /dev/null +++ b/providers/faux/faux.go @@ -0,0 +1,65 @@ +// Package faux is used exclusive for testing purposes. I would strongly suggest you move along +// as there's nothing to see here. +package faux + +import ( + "encoding/json" + "strings" + + "github.com/markbates/goth" +) + +// Provider is used only for testing. +type Provider struct { +} + +// Session is used only for testing. +type Session struct { + Name string + Email string +} + +// Name is used only for testing. +func (self *Provider) Name() string { + return "faux" +} + +// BeginAuth is used only for testing. +func (self *Provider) BeginAuth() (goth.Session, error) { + return &Session{}, nil +} + +// FetchUser is used only for testing. +func (self *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + return goth.User{ + Name: sess.Name, + Email: sess.Email, + }, nil +} + +// UnmarshalSession is used only for testing. +func (self *Provider) UnmarshalSession(data string) (goth.Session, error) { + sess := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(sess) + return sess, err +} + +// Debug is used only for testing. +func (self *Provider) Debug(debug bool) {} + +// Authorize is used only for testing. +func (self *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + return "", nil +} + +// Marshal is used only for testing. +func (self *Session) Marshal() string { + b, _ := json.Marshal(self) + return string(b) +} + +// GetAuthURL is used only for testing. +func (self *Session) GetAuthURL() (string, error) { + return "http://example.com/auth/", nil +} diff --git a/providers/twitter/session.go b/providers/twitter/session.go new file mode 100644 index 000000000..8ae757101 --- /dev/null +++ b/providers/twitter/session.go @@ -0,0 +1,45 @@ +package twitter + +import ( + "encoding/json" + "errors" + + "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 (self Session) GetAuthURL() (string, error) { + if self.AuthURL == "" { + return "", errors.New("an AuthURL has not be set") + } + return self.AuthURL, nil +} + +// Authorize the session with Twitter and return the access token to be stored for future use. +func (self *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + accessToken, err := p.consumer.AuthorizeToken(self.RequestToken, params.Get("oauth_verifier")) + if err != nil { + return "", err + } + self.AccessToken = accessToken + return accessToken.Token, err +} + +// Marshal the session into a string +func (self Session) Marshal() string { + b, _ := json.Marshal(self) + return string(b) +} + +func (self Session) String() string { + return self.Marshal() +} diff --git a/providers/twitter/session_test.go b/providers/twitter/session_test.go new file mode 100644 index 000000000..1773b07b9 --- /dev/null +++ b/providers/twitter/session_test.go @@ -0,0 +1,48 @@ +package twitter_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/twitter" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &twitter.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &twitter.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 := &twitter.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 := &twitter.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/twitter/twitter.go b/providers/twitter/twitter.go new file mode 100644 index 000000000..cfada0833 --- /dev/null +++ b/providers/twitter/twitter.go @@ -0,0 +1,113 @@ +// Package twitter 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 twitter + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "strings" + + "github.com/markbates/goth" + "github.com/mrjones/oauth" +) + +const ( + requestURL string = "https://api.twitter.com/oauth/request_token" + authURL string = "https://api.twitter.com/oauth/authorize" + tokenURL string = "https://api.twitter.com/oauth/access_token" + endpointProfile string = "https://api.twitter.com/1.1/account/verify_credentials.json" +) + +// 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. +func New(clientKey, secret, callbackURL string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + } + p.consumer = newConsumer(p) + return p +} + +// Provider is the implementation of `goth.Provider` for accessing Twitter. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + debug bool + consumer *oauth.Consumer +} + +// Name is the name used to retrieve this provider later. +func (self *Provider) Name() string { + return "twitter" +} + +// Debug sets the logging of the OAuth client to verbose. +func (self *Provider) Debug(debug bool) { + self.debug = debug +} + +// BeginAuth asks Twitter for an authentication end-point and a request token for a session. +func (self *Provider) BeginAuth() (goth.Session, error) { + requestToken, url, err := self.consumer.GetRequestTokenAndUrl(self.CallbackURL) + session := &Session{ + AuthURL: url, + RequestToken: requestToken, + } + return session, err +} + +// FetchUser will go to Twitter and access basic information about the user. +func (self *Provider) FetchUser(session goth.Session) (goth.User, error) { + user := goth.User{} + + sess := session.(*Session) + response, err := self.consumer.Get( + endpointProfile, + map[string]string{"include_entities": "false", "skip_status": "true"}, + sess.AccessToken) + if err != nil { + return user, err + } + defer response.Body.Close() + + bits, err := ioutil.ReadAll(response.Body) + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) + if err != nil { + return user, err + } + + user.Name = user.RawData["name"].(string) + user.NickName = user.RawData["screen_name"].(string) + user.Description = user.RawData["description"].(string) + user.AvatarURL = user.RawData["profile_image_url"].(string) + user.UserID = user.RawData["id_str"].(string) + user.Location = user.RawData["location"].(string) + user.AccessToken = sess.AccessToken.Token + return user, err +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (self *Provider) UnmarshalSession(data string) (goth.Session, error) { + sess := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(sess) + return sess, err +} + +func newConsumer(provider *Provider) *oauth.Consumer { + c := oauth.NewConsumer( + provider.ClientKey, + provider.Secret, + oauth.ServiceProvider{ + RequestTokenUrl: requestURL, + AuthorizeTokenUrl: authURL, + AccessTokenUrl: tokenURL, + }) + + c.Debug(provider.debug) + return c +} diff --git a/providers/twitter/twitter_test.go b/providers/twitter/twitter_test.go new file mode 100644 index 000000000..cf1075f87 --- /dev/null +++ b/providers/twitter/twitter_test.go @@ -0,0 +1,60 @@ +package twitter_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/twitter" + "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() + s := session.(*twitter.Session) + a.NoError(err) + a.Contains(s.AuthURL, "https://api.twitter.com/oauth/authorize?oauth_token=") + a.NotEmpty(s.RequestToken.Secret) + a.NotEmpty(s.RequestToken.Token) +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := twitterProvider() + + s, err := provider.UnmarshalSession(`{"AuthURL":"http://twitter.com/auth_url","AccessToken":{"Token":"1234567890","Secret":"secret!!","AdditionalData":{}},"RequestToken":{"Token":"0987654321","Secret":"!!secret"}}`) + a.NoError(err) + session := s.(*twitter.Session) + a.Equal(session.AuthURL, "http://twitter.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() *twitter.Provider { + return twitter.New(os.Getenv("TWITTER_KEY"), os.Getenv("TWITTER_SECRET"), "/foo") +} diff --git a/session.go b/session.go new file mode 100644 index 000000000..689aed7d3 --- /dev/null +++ b/session.go @@ -0,0 +1,21 @@ +package goth + +// Params is used to pass data to sessions for authorization. An existing +// implementation, and the one most likely to be used, is `url.Values`. +type Params interface { + Get(string) string +} + +// Session needs to be implemented as part of the provider package. +// It will be marshaled and persisted between requests to "tie" +// the start and the end of the authorization process with a +// 3rd party provider. +type Session interface { + // GetAuthURL returns the URL for the authentication end-point for the provider. + GetAuthURL() (string, error) + // Marshal generates a string representation of the Session for storing between requests. + Marshal() string + // Authorize should validate the data from the provider and return back an access token + // that can be stored for later access to the provider. + Authorize(Provider, Params) (string, error) +} diff --git a/user.go b/user.go new file mode 100644 index 000000000..3ad106bf8 --- /dev/null +++ b/user.go @@ -0,0 +1,15 @@ +package goth + +// User contains the information common amongst most OAuth and OAuth2 providers. +// All of the "raw" datafrom the provider can be found in the `RawData` field. +type User struct { + RawData map[string]interface{} + Email string + Name string + NickName string + Description string + UserID string + AvatarURL string + Location string + AccessToken string +} diff --git a/user_test.go b/user_test.go new file mode 100644 index 000000000..d5c015325 --- /dev/null +++ b/user_test.go @@ -0,0 +1 @@ +package goth_test