From aa848dc60ae39c6e75d42855adddd43566fc07bb Mon Sep 17 00:00:00 2001 From: Nikolay Bystritskiy Date: Fri, 23 Aug 2019 07:39:01 +0200 Subject: [PATCH] Support twitter auth (#33) * add oauth1 handler * add twitter as supported oauth provider * example for twitter * update documentation * fix oauth1 logout test --- README.md | 19 ++- _example/go.sum | 2 + _example/main.go | 1 + auth.go | 2 + go.mod | 1 + go.sum | 2 + provider/oauth1.go | 194 +++++++++++++++++++++++++ provider/oauth1_test.go | 291 +++++++++++++++++++++++++++++++++++++ provider/providers.go | 24 +++ provider/providers_test.go | 34 +++++ 10 files changed, 562 insertions(+), 8 deletions(-) create mode 100644 provider/oauth1.go create mode 100644 provider/oauth1_test.go diff --git a/README.md b/README.md index 12c2ed50..344be63e 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ Generally, adding support of `auth` includes a few relatively simple steps: For the example above authentication handlers wired as `/auth` and provides: -- `/auth//login?id=&from=` - site_id used as `aud` claim for the token and can be processed by `SecretReader` to load/retrieve/define different secrets. redirect_url is the url to redirect after successful login. +- `/auth//login?site=&from=` - site_id used as `aud` claim for the token and can be processed by `SecretReader` to load/retrieve/define different secrets. redirect_url is the url to redirect after successful login. - `/avatar/` - returns the avatar (image). Links to those pictures added into user info automatically, for details see "Avatar proxy" - `/auth//logout` and `/auth/logout` - invalidate "session" by removing JWT cookie - `/auth/list` - gives a json list of active providers @@ -190,12 +190,12 @@ The provider acts like any other, i.e. will be registered as `/auth/email/login` This provider brings two extra functions: 1. Adds ability to use any third-party oauth2 providers in addition to the list of directly supported. Included [example](https://github.com/go-pkgz/auth/blob/master/_example/main.go#L113) demonstrates how to do it for bitbucket. -In order to add a new oauth2 provider following input are required: - * `Name` - any name is allowed except the names from list of supported providers. It is possible to register more than one client for one given oauth2 provider (for example using diffrent names `bitbucket_dev` and `bitbucket_prod`) +In order to add a new oauth2 provider following input is required: + * `Name` - any name is allowed except the names from list of supported providers. It is possible to register more than one client for one given oauth2 provider (for example using different names `bitbucket_dev` and `bitbucket_prod`) * `Client` - ID and secret of client * `Endpoint` - auth URL and token URL. This information could be obtained from auth2 provider page * `InfoURL` - oauth2 provider API method to read information of logged in user. This method could be found in documentation of oauth2 provider (e.g. for bitbucket https://developer.atlassian.com/bitbucket/api/2/reference/resource/user) - * `MapUserFn` - function to convert the responce from `InfoURL` to `token.User` (s. example below) + * `MapUserFn` - function to convert the response from `InfoURL` to `token.User` (s. example below) * `Scopes` - minimal needed scope to read user information. Client should be authorized to these scopes ```go c := auth.Client{ @@ -221,7 +221,7 @@ In order to add a new oauth2 provider following input are required: }) ``` 2. Adds local oauth2 server user can fully customize. It uses [`gopkg.in/oauth2.v3`](https://github.com/go-oauth2/oauth2) library and example shows how [to initialize](https://github.com/go-pkgz/auth/blob/master/_example/main.go#L227) the server and [setup a provider](https://github.com/go-pkgz/auth/blob/master/_example/main.go#L100). - * to start local oauth2 server following options are requiered: + * to start local oauth2 server following options are required: * `URL` - url of oauth2 server with port * `WithLoginPage` - flag to define whether login page should be shown * `LoginPageHandler` - function to handle login request. If not specified default login page will be shown @@ -276,7 +276,7 @@ Such functionality can be implemented in 3 different ways: – Handling "allowed audience" as a part of `ClaimsUpdater` and `Validator` chain. I.e. `ClaimsUpdater` sets a claim indicating expected audience code/id and `Validator` making sure it matches. This way a single `auth.Service` could handle multiple groups of auth tokens and reject some based on the audience. - Using the standard JWT `aud` claim. This method conceptually very similar to the previous one, but done by library internally and consumer don't need to define special `ClaimsUpdater` and `Validator` logic. -In order to allow `aud` support the list of allowed audiences should be passed in as `opts.Audiences` parameter. Non-empty value will trigger internal checks for token generation (will reject token creation fot alien `aud`) as well as `Auth` middleware. +In order to allow `aud` support the list of allowed audiences should be passed in as `opts.Audiences` parameter. Non-empty value will trigger internal checks for token generation (will reject token creation for alien `aud`) as well as `Auth` middleware. ### Dev provider @@ -363,8 +363,11 @@ _instructions for google oauth2 setup borrowed from [oauth2_proxy](https://githu For more details refer to [Yandex OAuth](https://tech.yandex.com/oauth/doc/dg/concepts/about-docpage/) and [Yandex.Passport](https://tech.yandex.com/passport/doc/dg/index-docpage/) API documentation. - - +#### Twitter Auth Provider +1. Create a new twitter application https://developer.twitter.com/en/apps +1. Fill **App name** and **Description** and **URL** of your site +1. In the field **Callback URLs** enter the correct url of your callback handler e.g. https://example.mysite.com/{route}/twitter/callback +1. Under **Key and tokens** take note of the **Consumer API Key** and **Consumer API Secret key**. Those will be used as `cid` and `csecret` ## Status The library extracted from [remark42](https://github.com/umputun/remark) project. The original code in production use on multiple sites and seems to work fine. diff --git a/_example/go.sum b/_example/go.sum index 0913d10f..9ff48e7a 100644 --- a/_example/go.sum +++ b/_example/go.sum @@ -16,6 +16,8 @@ github.com/coreos/bbolt v1.3.3/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkE 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/dghubble/oauth1 v0.6.0 h1:m1yC01Ohc/eF38jwZ8JUjL1a+XHHXtGQgK+MxQbmSx0= +github.com/dghubble/oauth1 v0.6.0/go.mod h1:8pFdfPkv/jr8mkChVbNVuJ0suiHe278BtWI4Tk1ujxk= github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/eefret/gravatar v0.0.0-20181201135945-2163a437cdca/go.mod h1:INXlE8NSNzQOzkK8ycoVKV8V+zQi70Ms3Bx/KyOuYgs= diff --git a/_example/main.go b/_example/main.go index d41aa9b3..f53ab30b 100644 --- a/_example/main.go +++ b/_example/main.go @@ -73,6 +73,7 @@ func main() { service := auth.NewService(options) service.AddProvider("dev", "", "") // add dev provider service.AddProvider("github", os.Getenv("AEXMPL_GITHUB_CID"), os.Getenv("AEXMPL_GITHUB_CSEC")) // add github provider + service.AddProvider("twitter", os.Getenv("AEXMPL_TWITTER_APIKEY"), os.Getenv("AEXMPL_TWITTER_APISEC")) // allow anonymous user via custom (direct) provider service.AddDirectProvider("anonymous", anonymousAuthProvider()) diff --git a/auth.go b/auth.go index b6118998..038e1a75 100644 --- a/auth.go +++ b/auth.go @@ -220,6 +220,8 @@ func (s *Service) AddProvider(name, cid, csecret string) { s.providers = append(s.providers, provider.NewService(provider.NewFacebook(p))) case "yandex": s.providers = append(s.providers, provider.NewService(provider.NewYandex(p))) + case "twitter": + s.providers = append(s.providers, provider.NewService(provider.NewTwitter(p))) case "dev": s.providers = append(s.providers, provider.NewService(provider.NewDev(p))) default: diff --git a/go.mod b/go.mod index 46a36f8b..afe93f44 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/go-pkgz/auth require ( cloud.google.com/go v0.40.0 // indirect github.com/coreos/bbolt v1.3.3 + github.com/dghubble/oauth1 v0.6.0 github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8 github.com/go-chi/chi v4.0.2+incompatible // indirect diff --git a/go.sum b/go.sum index 370f1d86..b986cd35 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,8 @@ github.com/coreos/bbolt v1.3.3/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkE 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/dghubble/oauth1 v0.6.0 h1:m1yC01Ohc/eF38jwZ8JUjL1a+XHHXtGQgK+MxQbmSx0= +github.com/dghubble/oauth1 v0.6.0/go.mod h1:8pFdfPkv/jr8mkChVbNVuJ0suiHe278BtWI4Tk1ujxk= github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= diff --git a/provider/oauth1.go b/provider/oauth1.go new file mode 100644 index 00000000..6e7720e1 --- /dev/null +++ b/provider/oauth1.go @@ -0,0 +1,194 @@ +package provider + +import ( + "context" + "encoding/json" + "io/ioutil" + "net/http" + "strings" + "time" + + "github.com/dghubble/oauth1" + "github.com/dgrijalva/jwt-go" + "github.com/go-pkgz/auth/logger" + "github.com/go-pkgz/auth/token" + "github.com/go-pkgz/rest" +) + +// Oauth1Handler implements /login, /callback and /logout handlers for oauth1 flow +type Oauth1Handler struct { + Params + name string + infoURL string + conf oauth1.Config + mapUser func(UserData, []byte) token.User // map info from InfoURL to User +} + +// Name returns provider name +func (h Oauth1Handler) Name() string { return h.name } + +// LoginHandler - GET /login?from=redirect-back-url&site=siteID&session=1 +func (h Oauth1Handler) LoginHandler(w http.ResponseWriter, r *http.Request) { + h.Logf("[DEBUG] login with %s", h.Name()) + + // setting RedirectURL to {rootURL}/{routingPath}/{provider}/callback + // e.g. http://localhost:8080/auth/twitter/callback + h.conf.CallbackURL = h.makeRedirURL(r.URL.Path) + + requestToken, requestSecret, err := h.conf.RequestToken() + if err != nil { + rest.SendErrorJSON(w, r, h.L, http.StatusInternalServerError, err, "failed to get request token") + return + } + + // use requestSecret as a state in oauth2 + cid, err := randToken() + if err != nil { + rest.SendErrorJSON(w, r, h.L, http.StatusInternalServerError, err, "failed to make claim's id") + return + } + + claims := token.Claims{ + Handshake: &token.Handshake{ + State: requestSecret, + From: r.URL.Query().Get("from"), + }, + SessionOnly: r.URL.Query().Get("session") != "" && r.URL.Query().Get("session") != "0", + StandardClaims: jwt.StandardClaims{ + Id: cid, + Audience: r.URL.Query().Get("site"), + ExpiresAt: time.Now().Add(30 * time.Minute).Unix(), + NotBefore: time.Now().Add(-1 * time.Minute).Unix(), + }, + } + + if _, err = h.JwtService.Set(w, claims); err != nil { + rest.SendErrorJSON(w, r, h.L, http.StatusInternalServerError, err, "failed to set token") + return + } + + authURL, err := h.conf.AuthorizationURL(requestToken) + if err != nil { + rest.SendErrorJSON(w, r, h.L, http.StatusInternalServerError, err, "failed to obtain oauth1 URL") + return + } + + http.Redirect(w, r, authURL.String(), http.StatusFound) +} + +// AuthHandler fills user info and redirects to "from" url. This is callback url redirected locally by browser +// GET /callback +func (h Oauth1Handler) AuthHandler(w http.ResponseWriter, r *http.Request) { + oauthClaims, _, err := h.JwtService.Get(r) + if err != nil { + rest.SendErrorJSON(w, r, h.L, http.StatusInternalServerError, err, "failed to get token") + return + } + + requestToken, verifier, err := oauth1.ParseAuthorizationCallback(r) + if err != nil { + rest.SendErrorJSON(w, r, h.L, http.StatusInternalServerError, err, "failed to parse response from oauth1 server") + return + } + + accessToken, accessSecret, err := h.conf.AccessToken(requestToken, oauthClaims.Handshake.State, verifier) + if err != nil { + rest.SendErrorJSON(w, r, h.L, http.StatusInternalServerError, err, "failed to get accessToken and accessSecret") + return + } + + tok := oauth1.NewToken(accessToken, accessSecret) + client := h.conf.Client(context.Background(), tok) + + uinfo, err := client.Get(h.infoURL) + if err != nil { + rest.SendErrorJSON(w, r, h.L, http.StatusServiceUnavailable, err, "failed to get client info") + return + } + + defer func() { + if e := uinfo.Body.Close(); e != nil { + h.Logf("[WARN] failed to close response body, %s", e) + } + }() + + data, err := ioutil.ReadAll(uinfo.Body) + if err != nil { + rest.SendErrorJSON(w, r, h.L, http.StatusInternalServerError, err, "failed to read user info") + return + } + + jData := map[string]interface{}{} + if e := json.Unmarshal(data, &jData); e != nil { + rest.SendErrorJSON(w, r, h.L, http.StatusInternalServerError, err, "failed to unmarshal user info") + return + } + h.Logf("[DEBUG] got raw user info %+v", jData) + + u := h.mapUser(jData, data) + u, err = setAvatar(h.AvatarSaver, u) + if err != nil { + rest.SendErrorJSON(w, r, h.L, http.StatusInternalServerError, err, "failed to save avatar to proxy") + return + } + + cid, err := randToken() + if err != nil { + rest.SendErrorJSON(w, r, h.L, http.StatusInternalServerError, err, "failed to make claim's id") + return + } + claims := token.Claims{ + User: &u, + StandardClaims: jwt.StandardClaims{ + Issuer: h.Issuer, + Id: cid, + Audience: oauthClaims.Audience, + }, + SessionOnly: oauthClaims.SessionOnly, + } + + if _, err = h.JwtService.Set(w, claims); err != nil { + rest.SendErrorJSON(w, r, h.L, http.StatusInternalServerError, err, "failed to set token") + return + } + + h.Logf("[DEBUG] user info %+v", u) + + // redirect to back url if presented in login query params + if oauthClaims.Handshake != nil && oauthClaims.Handshake.From != "" { + http.Redirect(w, r, oauthClaims.Handshake.From, http.StatusTemporaryRedirect) + return + } + rest.RenderJSON(w, r, &u) +} + +// LogoutHandler - GET /logout +func (h Oauth1Handler) LogoutHandler(w http.ResponseWriter, r *http.Request) { + if _, _, err := h.JwtService.Get(r); err != nil { + rest.SendErrorJSON(w, r, h.L, http.StatusForbidden, err, "logout not allowed") + return + } + h.JwtService.Reset(w) +} + +func (h Oauth1Handler) makeRedirURL(path string) string { + elems := strings.Split(path, "/") + newPath := strings.Join(elems[:len(elems)-1], "/") + + return strings.TrimRight(h.URL, "/") + strings.TrimRight(newPath, "/") + urlCallbackSuffix +} + +// initOauth2Handler makes oauth1 handler for given provider +func initOauth1Handler(p Params, service Oauth1Handler) Oauth1Handler { + if p.L == nil { + p.L = logger.NoOp + } + p.Logf("[INFO] init oauth1 service %s", service.name) + service.Params = p + service.conf.ConsumerKey = p.Cid + service.conf.ConsumerSecret = p.Csecret + + p.Logf("[DEBUG] created %s oauth2, id=%s, redir=%s, endpoint=%s", + service.name, service.Cid, service.makeRedirURL("/{route}/"+service.name+"/"), service.conf.Endpoint) + return service +} diff --git a/provider/oauth1_test.go b/provider/oauth1_test.go new file mode 100644 index 00000000..b75aca49 --- /dev/null +++ b/provider/oauth1_test.go @@ -0,0 +1,291 @@ +package provider + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net/http" + "net/http/cookiejar" + "strings" + "testing" + "time" + + "github.com/dghubble/oauth1" + "github.com/go-pkgz/auth/logger" + "github.com/go-pkgz/auth/token" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + timeout = 100 + loginPort = 8983 + authPort = 8984 +) + +func TestOauth1Login(t *testing.T) { + teardown := prepOauth1Test(t, loginPort, authPort) + defer teardown() + + jar, err := cookiejar.New(nil) + require.Nil(t, err) + client := &http.Client{Jar: jar, Timeout: timeout * time.Second} + + // check non-admin, permanent + resp, err := client.Get(fmt.Sprintf("http://localhost:%d/login?site=remark", loginPort)) + require.Nil(t, err) + assert.Equal(t, 200, resp.StatusCode) + body, err := ioutil.ReadAll(resp.Body) + assert.Nil(t, err) + t.Logf("resp %s", string(body)) + t.Logf("headers: %+v", resp.Header) + + assert.Equal(t, 2, len(resp.Cookies())) + assert.Equal(t, "JWT", resp.Cookies()[0].Name) + assert.NotEqual(t, "", resp.Cookies()[0].Value, "token set") + assert.Equal(t, 2678400, resp.Cookies()[0].MaxAge) + assert.Equal(t, "XSRF-TOKEN", resp.Cookies()[1].Name) + assert.NotEqual(t, "", resp.Cookies()[1].Value, "xsrf cookie set") + + u := token.User{} + err = json.Unmarshal(body, &u) + assert.Nil(t, err) + assert.Equal(t, token.User{Name: "blah", ID: "mock_myuser1", Picture: "http://example.com/custom.png", IP: ""}, u) + + tk := resp.Cookies()[0].Value + jwtSvc := token.NewService(token.Opts{SecretReader: token.SecretFunc(mockKeyStore), SecureCookies: false, + TokenDuration: time.Hour, CookieDuration: days31}) + + claims, err := jwtSvc.Parse(tk) + require.NoError(t, err) + t.Log(claims) + assert.Equal(t, "remark42", claims.Issuer) + assert.Equal(t, "remark", claims.Audience) + + // check admin user + resp, err = client.Get(fmt.Sprintf("http://localhost:%d/login?site=remark", loginPort)) + assert.Nil(t, err) + assert.Equal(t, 200, resp.StatusCode) + body, err = ioutil.ReadAll(resp.Body) + assert.Nil(t, err) + u = token.User{} + err = json.Unmarshal(body, &u) + assert.Nil(t, err) + assert.Equal(t, token.User{Name: "blah", ID: "mock_myuser2", Picture: "http://example.com/ava12345.png", + Attributes: map[string]interface{}{"admin": true}}, u) + +} + +func TestOauth1LoginSessionOnly(t *testing.T) { + + teardown := prepOauth1Test(t, loginPort, authPort) + defer teardown() + + jar, err := cookiejar.New(nil) + require.Nil(t, err) + client := &http.Client{Jar: jar, Timeout: timeout * time.Second} + + // check non-admin, session + resp, err := client.Get(fmt.Sprintf("http://localhost:%d/login?site=remark&session=1", loginPort)) + require.Nil(t, err) + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, 2, len(resp.Cookies())) + assert.Equal(t, "JWT", resp.Cookies()[0].Name) + assert.NotEqual(t, "", resp.Cookies()[0].Value, "token set") + assert.Equal(t, 0, resp.Cookies()[0].MaxAge) + assert.Equal(t, "XSRF-TOKEN", resp.Cookies()[1].Name) + assert.NotEqual(t, "", resp.Cookies()[1].Value, "xsrf cookie set") + + req, err := http.NewRequest("GET", "http://example.com", nil) + require.Nil(t, err) + + req.AddCookie(resp.Cookies()[0]) + req.AddCookie(resp.Cookies()[1]) + req.Header.Add("X-XSRF-TOKEN", resp.Cookies()[1].Value) + + jwtService := token.NewService(token.Opts{SecretReader: token.SecretFunc(mockKeyStore)}) + res, _, err := jwtService.Get(req) + require.Nil(t, err) + assert.Equal(t, true, res.SessionOnly) + t.Logf("%+v", res) +} + +func TestOauth1Logout(t *testing.T) { + + teardown := prepOauth1Test(t, loginPort, authPort) + defer teardown() + + jar, err := cookiejar.New(nil) + require.Nil(t, err) + client := &http.Client{Jar: jar, Timeout: timeout * time.Second} + + req, err := http.NewRequest("GET", fmt.Sprintf("http://localhost:%d/logout", loginPort), nil) + require.Nil(t, err) + resp, err := client.Do(req) + require.Nil(t, err) + assert.Equal(t, 403, resp.StatusCode, "user not lagged in") + + req, err = http.NewRequest("GET", fmt.Sprintf("http://localhost:%d/logout", loginPort), nil) + require.NoError(t, err) + expiration := int(time.Duration(365 * 24 * time.Hour).Seconds()) //nolint + req.AddCookie(&http.Cookie{Name: "JWT", Value: testJwtValid, HttpOnly: true, Path: "/", MaxAge: expiration, Secure: false}) + req.Header.Add("X-XSRF-TOKEN", "random id") + resp, err = client.Do(req) + require.Nil(t, err) + require.Equal(t, 200, resp.StatusCode) + + assert.Equal(t, 2, len(resp.Cookies())) + assert.Equal(t, "JWT", resp.Cookies()[0].Name, "token cookie cleared") + assert.Equal(t, "", resp.Cookies()[0].Value) + assert.Equal(t, "XSRF-TOKEN", resp.Cookies()[1].Name, "xsrf cookie cleared") + assert.Equal(t, "", resp.Cookies()[1].Value) +} + +func TestOauth1InitProvider(t *testing.T) { + params := Params{URL: "url", Cid: "cid", Csecret: "csecret", Issuer: "app-test"} + provider := Oauth1Handler{name: "test"} + res := initOauth1Handler(params, provider) + assert.Equal(t, "cid", res.conf.ConsumerKey) + assert.Equal(t, "csecret", res.conf.ConsumerSecret) + assert.Equal(t, "test", res.name) + assert.Equal(t, "app-test", res.Issuer) +} + +func TestOauth1InvalidHandler(t *testing.T) { + teardown := prepOauth1Test(t, loginPort, authPort) + defer teardown() + + client := &http.Client{Timeout: timeout * time.Second} + resp, err := client.Get(fmt.Sprintf("http://localhost:%d/login_bad", loginPort)) + require.Nil(t, err) + assert.Equal(t, 404, resp.StatusCode) + + resp, err = client.Post(fmt.Sprintf("http://localhost:%d/login", loginPort), "", nil) + require.Nil(t, err) + assert.Equal(t, 405, resp.StatusCode) +} + +func TestOauth1MakeRedirURL(t *testing.T) { + cases := []struct{ rootURL, route, out string }{ + {"localhost:8080/", "/my/auth/path/google", "localhost:8080/my/auth/path/callback"}, + {"localhost:8080", "/auth/google", "localhost:8080/auth/callback"}, + {"localhost:8080/", "/auth/google", "localhost:8080/auth/callback"}, + {"localhost:8080", "/", "localhost:8080/callback"}, + {"localhost:8080/", "/", "localhost:8080/callback"}, + {"mysite.com", "", "mysite.com/callback"}, + } + + for i := range cases { + c := cases[i] + oh := initOauth1Handler(Params{URL: c.rootURL}, Oauth1Handler{}) + assert.Equal(t, c.out, oh.makeRedirURL(c.route)) + } +} + +func prepOauth1Test(t *testing.T, loginPort, authPort int) func() { + + provider := Oauth1Handler{ + name: "mock", + conf: oauth1.Config{ + Endpoint: oauth1.Endpoint{ + RequestTokenURL: fmt.Sprintf("http://localhost:%d/login/oauth/request_token", authPort), + AuthorizeURL: fmt.Sprintf("http://localhost:%d/login/oauth/authorize", authPort), + AccessTokenURL: fmt.Sprintf("http://localhost:%d/login/oauth/access_token", authPort), + }, + }, + infoURL: fmt.Sprintf("http://localhost:%d/user", authPort), + mapUser: func(data UserData, _ []byte) token.User { + userInfo := token.User{ + ID: "mock_" + data.Value("id"), + Name: data.Value("name"), + Picture: data.Value("picture"), + } + return userInfo + }, + } + + jwtService := token.NewService(token.Opts{ + SecretReader: token.SecretFunc(mockKeyStore), SecureCookies: false, TokenDuration: time.Hour, CookieDuration: days31, + ClaimsUpd: token.ClaimsUpdFunc(func(claims token.Claims) token.Claims { + if claims.User != nil { + switch claims.User.ID { + case "mock_myuser2": + claims.User.SetBoolAttr("admin", true) + case "mock_myuser1": + claims.User.Picture = "http://example.com/custom.png" + } + } + return claims + }), + }) + + params := Params{URL: "url", Cid: "aFdj12348sdja", Csecret: "Dwehsq2387akss", JwtService: jwtService, + Issuer: "remark42", AvatarSaver: &mockAvatarSaver{}, L: logger.Std} + + provider = initOauth1Handler(params, provider) + svc := Service{Provider: provider} + + ts := &http.Server{Addr: fmt.Sprintf(":%d", loginPort), Handler: http.HandlerFunc(svc.Handler)} + + count := 0 + useIds := []string{"myuser1", "myuser2"} // user for first ans second calls + + var ( + requestToken = "sdjasd09AfdkzztyRadrdR" + requestSecret = "asd34q129sjdklAJJAs" + verifier = "gsjad032ajjjOIU" + accessToken = "g0ZGZmNjVmOWI" + accessSecret = "qfr1239UJAkmpaf3l" + ) + + oauth := &http.Server{ + Addr: fmt.Sprintf(":%d", authPort), + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Printf("[MOCK OAUTH] request %s %s %+v", r.Method, r.URL, r.Header) + switch { + case strings.HasPrefix(r.URL.Path, "/login/oauth/request_token"): + w.Header().Set("Content-Type", "application/x-www-form-urlencoded") + _, err := w.Write([]byte(fmt.Sprintf(`oauth_token=%s&oauth_token_secret=%s&oauth_callback_confirmed=%s`, requestToken, requestSecret, "true"))) + if err != nil { + w.WriteHeader(500) + return + } + case strings.HasPrefix(r.URL.Path, "/login/oauth/authorize"): + w.Header().Add("Location", fmt.Sprintf("http://localhost:%d/callback?oauth_token=%s&oauth_verifier=%s", + loginPort, requestToken, verifier)) + w.WriteHeader(302) + case strings.HasPrefix(r.URL.Path, "/login/oauth/access_token"): + w.Header().Set("Content-Type", "application/x-www-form-urlencoded") + _, err := w.Write([]byte(fmt.Sprintf(`oauth_token=%s&oauth_token_secret=%s`, accessToken, accessSecret))) + if err != nil { + w.WriteHeader(500) + return + } + w.WriteHeader(200) + case strings.HasPrefix(r.URL.Path, "/user"): + res := fmt.Sprintf(`{ + "id": "%s", + "name":"blah", + "picture":"http://exmple.com/pic1.png" + }`, useIds[count]) + count++ + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _, err := w.Write([]byte(res)) + assert.NoError(t, err) + default: + t.Fatalf("unexpected oauth request %s %s", r.Method, r.URL) + } + }), + } + + go func() { _ = oauth.ListenAndServe() }() + go func() { _ = ts.ListenAndServe() }() + + time.Sleep(time.Millisecond * 400) // let them start + + return func() { + assert.NoError(t, ts.Close()) + assert.NoError(t, oauth.Close()) + } +} diff --git a/provider/providers.go b/provider/providers.go index 155d0193..3ac4c71f 100644 --- a/provider/providers.go +++ b/provider/providers.go @@ -10,6 +10,8 @@ import ( "golang.org/x/oauth2/google" "golang.org/x/oauth2/yandex" + "github.com/dghubble/oauth1" + "github.com/dghubble/oauth1/twitter" "github.com/go-pkgz/auth/token" ) @@ -121,3 +123,25 @@ func NewYandex(p Params) Oauth2Handler { }, }) } + +// NewTwitter makes twitter oauth2 provider +func NewTwitter(p Params) Oauth1Handler { + return initOauth1Handler(p, Oauth1Handler{ + name: "twitter", + conf: oauth1.Config{ + Endpoint: twitter.AuthorizeEndpoint, + }, + infoURL: "https://api.twitter.com/1.1/account/verify_credentials.json", + mapUser: func(data UserData, _ []byte) token.User { + userInfo := token.User{ + ID: "twitter_" + token.HashID(sha1.New(), data.Value("id_str")), + Name: data.Value("screen_name"), + Picture: data.Value("profile_image_url_https"), + } + if userInfo.Name == "" { + userInfo.Name = data.Value("name") + } + return userInfo + }, + }) +} diff --git a/provider/providers_test.go b/provider/providers_test.go index 0689231e..4075a9e2 100644 --- a/provider/providers_test.go +++ b/provider/providers_test.go @@ -82,3 +82,37 @@ func TestProviders_NewYandex(t *testing.T) { assert.Equal(t, token.User{Name: "vasya", ID: "yandex_01b307acba4f54f55aafc33bb06bbbf6ca803e9a", Picture: "", IP: ""}, user, "got %+v", user) } + +func TestProviders_NewTwitter(t *testing.T) { + r := NewTwitter(Params{URL: "http://demo.remark42.com", Cid: "cid", Csecret: "cs"}) + assert.Equal(t, "twitter", r.Name()) + + cases := []struct { + udata UserData + uopts []byte + expected token.User + }{ + {udata: UserData{"id_str": "myid", "name": "test user", "profile_image_url_https": "https://demo.remark42.com/blah.png"}, + uopts: []byte(``), + expected: token.User{Name: "test user", ID: "twitter_6e34471f84557e1713012d64a7477c71bfdac631", + Picture: "https://demo.remark42.com/blah.png", IP: ""}, + }, + {udata: UserData{"id_str": "124381237", "screen_name": "Bob", "name": "Robert Downey Jr.", "profile_image_url_https": ""}, + uopts: []byte(``), + expected: token.User{Name: "Bob", ID: "twitter_63a6b20b6e17fb5e17f6c58b6223e3b760ad510e", + Picture: "", IP: ""}, + }, + {udata: UserData{"id_str": "124381237", "name": "Robert Downey Jr.", "profile_image_url_https": "https://demo.remark42.com/blah.png"}, + uopts: []byte(``), + expected: token.User{Name: "Robert Downey Jr.", ID: "twitter_63a6b20b6e17fb5e17f6c58b6223e3b760ad510e", + Picture: "https://demo.remark42.com/blah.png", IP: ""}, + }, + } + + for i := range cases { + c := cases[i] + got := r.mapUser(c.udata, c.uopts) + assert.Equal(t, c.expected, got, "got %+v", got) + } + +}