Skip to content

Commit

Permalink
Support twitter auth (#33)
Browse files Browse the repository at this point in the history
* add oauth1 handler

* add twitter as supported oauth provider

* example for twitter

* update documentation

* fix oauth1 logout test
  • Loading branch information
nbys authored and umputun committed Aug 23, 2019
1 parent 6dfd65f commit aa848dc
Show file tree
Hide file tree
Showing 10 changed files with 562 additions and 8 deletions.
19 changes: 11 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<provider>/login?id=<site_id>&from=<redirect_url>` - 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/<provider>/login?site=<site_id>&from=<redirect_url>` - 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/<avatar_id>` - returns the avatar (image). Links to those pictures added into user info automatically, for details see "Avatar proxy"
- `/auth/<provider>/logout` and `/auth/logout` - invalidate "session" by removing JWT cookie
- `/auth/list` - gives a json list of active providers
Expand Down Expand Up @@ -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{
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions _example/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
1 change: 1 addition & 0 deletions _example/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
2 changes: 2 additions & 0 deletions auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
194 changes: 194 additions & 0 deletions provider/oauth1.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading

0 comments on commit aa848dc

Please sign in to comment.