auth

Paddy 2015-05-15 Parent:0ff23f3a4ede Child:37a42585660e

168:581c60f8dd23 Browse Files

Switch to a JWT approach. We're going to use a JWT as our access tokens (as discussed in &yet's excellent post https://blog.andyet.com/2015/05/12/micro-services-user-info-and-auth and my ensuing conversation with Fritzy). The benefit of this approach is that we can do authentication and even some authorization without touching the database at all. The drawback is that we can no longer revoke access tokens, only the refresh tokens that grant the access tokens. We need a new config variable to set our private key, used to sign the JWT. We get to remove our token handlers, as we no longer can revoke tokens, so there's no purpose in getting information about it or listing them. Our tokenStore revokeToken gets to be simplified, as it will only ever be used for refresh tokens now. We also updated our postgres and memstore implementations. We added a helper method for generating the signed "access token" (our JWT) and started using it in the places where we're creating a Token. We get to remove the `revoked` SQL column for the tokens table, and rename the `refresh_revoked` column to just be `revoked`. We shortened our access token expiration to 15 minutes instead of an hour, to deal with the token not being revokable.

authd/server.go config.go context.go oauth2.go oauth2_test.go profile.go request.go sql/postgres_init.sql token.go token_postgres.go token_test.go

     1.1 --- a/authd/server.go	Tue May 12 21:14:21 2015 -0400
     1.2 +++ b/authd/server.go	Fri May 15 19:44:40 2015 -0400
     1.3 @@ -35,6 +35,7 @@
     1.4  	}
     1.5  	config.Template = template.Must(template.New("base").ParseGlob("./templates/*.gotmpl"))
     1.6  	config.LoginURI = "/login"
     1.7 +	config.JWTPrivateKey = []byte(`secret`)
     1.8  	err := config.Init()
     1.9  	if err != nil {
    1.10  		log.Fatal(err)
    1.11 @@ -55,7 +56,6 @@
    1.12  	auth.RegisterSessionHandlers(router, context)
    1.13  	auth.RegisterProfileHandlers(router, context)
    1.14  	auth.RegisterClientHandlers(router, context)
    1.15 -	auth.RegisterTokenHandlers(router, context)
    1.16  	http.Handle("/", router)
    1.17  	log.Fatal(http.ListenAndServe(":8080", nil))
    1.18  }
     2.1 --- a/config.go	Tue May 12 21:14:21 2015 -0400
     2.2 +++ b/config.go	Fri May 15 19:44:40 2015 -0400
     2.3 @@ -24,6 +24,7 @@
     2.4  	ScopeStore    scopeStore
     2.5  	Template      *template.Template
     2.6  	LoginURI      string
     2.7 +	JWTPrivateKey []byte
     2.8  	iterations    int
     2.9  	secureCookie  bool
    2.10  }
     3.1 --- a/context.go	Tue May 12 21:14:21 2015 -0400
     3.2 +++ b/context.go	Fri May 15 19:44:40 2015 -0400
     3.3 @@ -349,13 +349,12 @@
     3.4  }
     3.5  
     3.6  // RevokeToken revokes the Token identfied by the passed token string from the tokenStore associated
     3.7 -// with the context. If refresh is true, the token input should be compared against the refresh tokens,
     3.8 -// not the access tokens.
     3.9 -func (c Context) RevokeToken(token string, refresh bool) error {
    3.10 +// with the context.
    3.11 +func (c Context) RevokeToken(token string) error {
    3.12  	if c.tokens == nil {
    3.13  		return ErrNoTokenStore
    3.14  	}
    3.15 -	return c.tokens.revokeToken(token, refresh)
    3.16 +	return c.tokens.revokeToken(token)
    3.17  }
    3.18  
    3.19  // RevokeTokensByProfileID revokes the Tokens associated with the Profile identified by the passed ID in
     4.1 --- a/oauth2.go	Tue May 12 21:14:21 2015 -0400
     4.2 +++ b/oauth2.go	Fri May 15 19:44:40 2015 -0400
     4.3 @@ -332,7 +332,6 @@
     4.4  				q.Add("code", authCode.Code)
     4.5  			case "token":
     4.6  				token := Token{
     4.7 -					AccessToken: uuid.NewID().String(),
     4.8  					Created:     time.Now(),
     4.9  					CreatedFrom: "implicit",
    4.10  					ExpiresIn:   defaultTokenExpiration,
    4.11 @@ -341,7 +340,14 @@
    4.12  					ProfileID:   session.ProfileID,
    4.13  					ClientID:    clientID,
    4.14  				}
    4.15 -				err := context.SaveToken(token)
    4.16 +				access, err := token.GenerateAccessToken(context.config.JWTPrivateKey)
    4.17 +				if err != nil {
    4.18 +					log.Printf("Error signing token: %+v\n", err)
    4.19 +					q.Add("error", "server_error")
    4.20 +					break
    4.21 +				}
    4.22 +				token.AccessToken = access
    4.23 +				err = context.SaveToken(token)
    4.24  				if err != nil {
    4.25  					log.Println("Error saving token:", err)
    4.26  					q.Add("error", "server_error")
    4.27 @@ -420,7 +426,15 @@
    4.28  		ProfileID:    profileID,
    4.29  		ClientID:     clientID,
    4.30  	}
    4.31 -	err := context.SaveToken(token)
    4.32 +	access, err := token.GenerateAccessToken(context.config.JWTPrivateKey)
    4.33 +	if err != nil {
    4.34 +		log.Printf("Error signing token: %+v\n", err)
    4.35 +		w.WriteHeader(http.StatusInternalServerError)
    4.36 +		renderJSONError(enc, "server_error")
    4.37 +		return
    4.38 +	}
    4.39 +	token.AccessToken = access
    4.40 +	err = context.SaveToken(token)
    4.41  	if err != nil {
    4.42  		w.WriteHeader(http.StatusInternalServerError)
    4.43  		renderJSONError(enc, "server_error")
     5.1 --- a/oauth2_test.go	Tue May 12 21:14:21 2015 -0400
     5.2 +++ b/oauth2_test.go	Fri May 15 19:44:40 2015 -0400
     5.3 @@ -776,6 +776,9 @@
     5.4  		clients:   store,
     5.5  		authCodes: store,
     5.6  		tokens:    store,
     5.7 +		config: Config{
     5.8 +			JWTPrivateKey: []byte("this is totally a secret, secure private key"),
     5.9 +		},
    5.10  	}
    5.11  	client := Client{
    5.12  		ID:      uuid.NewID(),
     6.1 --- a/profile.go	Tue May 12 21:14:21 2015 -0400
     6.2 +++ b/profile.go	Fri May 15 19:44:40 2015 -0400
     6.3 @@ -463,7 +463,6 @@
     6.4  	r.Handle("/profiles/{id}", wrap(context, GetProfileHandler)).Methods("GET", "OPTIONS")
     6.5  	r.Handle("/profiles/{id}", wrap(context, UpdateProfileHandler)).Methods("PATCH", "OPTIONS")
     6.6  	r.Handle("/profiles/{id}", wrap(context, DeleteProfileHandler)).Methods("DELETE", "OPTIONS")
     6.7 -	// TODO: r.Handle("/profiles/{id}/tokens", wrap(context, ListTokensHandler)).Methods("GET", "OPTIONS")
     6.8  	// BUG(paddy): We need to implement a handler that will add a login to a profile.
     6.9  	// BUG(paddy): We need to implement a handler that will remove a login from a profile. What happens to sessions created with that login?
    6.10  	// BUG(paddy): We need to implement a handler that will list the logins attached to a profile.
     7.1 --- a/request.go	Tue May 12 21:14:21 2015 -0400
     7.2 +++ b/request.go	Fri May 15 19:44:40 2015 -0400
     7.3 @@ -34,7 +34,6 @@
     7.4  	Clients   []Client       `json:"clients,omitempty"`
     7.5  	Endpoints []Endpoint     `json:"endpoints,omitempty"`
     7.6  	Sessions  []Session      `json:"sessions,omitempty"`
     7.7 -	Tokens    []Token        `json:"tokens,omitempty"`
     7.8  }
     7.9  
    7.10  type requestError struct {
     8.1 --- a/sql/postgres_init.sql	Tue May 12 21:14:21 2015 -0400
     8.2 +++ b/sql/postgres_init.sql	Fri May 15 19:44:40 2015 -0400
     8.3 @@ -59,7 +59,7 @@
     8.4  );
     8.5  
     8.6  CREATE TABLE IF NOT EXISTS tokens (
     8.7 -	access_token VARCHAR(36) PRIMARY KEY,
     8.8 +	access_token TEXT PRIMARY KEY,
     8.9  	refresh_token VARCHAR(36) UNIQUE NOT NULL,
    8.10  	created TIMESTAMPTZ NOT NULL,
    8.11  	created_from VARCHAR(128) NOT NULL,
    8.12 @@ -68,7 +68,6 @@
    8.13  	profile_id VARCHAR(36) NOT NULL,
    8.14  	client_id VARCHAR(36) NOT NULL,
    8.15  	revoked BOOLEAN NOT NULL,
    8.16 -	refresh_revoked BOOLEAN NOT NULL,
    8.17  	scopes varchar(64)[] NOT NULL
    8.18  );
    8.19  
     9.1 --- a/token.go	Tue May 12 21:14:21 2015 -0400
     9.2 +++ b/token.go	Fri May 15 19:44:40 2015 -0400
     9.3 @@ -3,16 +3,18 @@
     9.4  import (
     9.5  	"encoding/json"
     9.6  	"errors"
     9.7 -	"github.com/gorilla/mux"
     9.8  	"log"
     9.9  	"net/http"
    9.10 +	"strings"
    9.11  	"time"
    9.12  
    9.13  	"code.secondbit.org/uuid.hg"
    9.14 +
    9.15 +	"github.com/dgrijalva/jwt-go"
    9.16  )
    9.17  
    9.18  const (
    9.19 -	defaultTokenExpiration = 3600 // one hour
    9.20 +	defaultTokenExpiration = 900 // fifteen minutes
    9.21  )
    9.22  
    9.23  func init() {
    9.24 @@ -38,23 +40,35 @@
    9.25  // Token represents an access and/or refresh token that the Client can use to access user data
    9.26  // or obtain a new access token.
    9.27  type Token struct {
    9.28 -	AccessToken    string    `json:"access_token"`
    9.29 -	RefreshToken   string    `json:"refresh_token,omitempty"`
    9.30 -	Created        time.Time `json:"-"`
    9.31 -	CreatedFrom    string    `json:"created_from"`
    9.32 -	ExpiresIn      int32     `json:"expires_in"`
    9.33 -	TokenType      string    `json:"token_type"`
    9.34 -	Scopes         Scopes    `json:"-"`
    9.35 -	ProfileID      uuid.ID   `json:"profile_id"`
    9.36 -	ClientID       uuid.ID   `json:"client_id"`
    9.37 -	Revoked        bool      `json:"revoked,omitempty"`
    9.38 -	RefreshRevoked bool      `json:"refresh_revoked,omitempty"`
    9.39 +	AccessToken  string
    9.40 +	RefreshToken string
    9.41 +	Created      time.Time
    9.42 +	CreatedFrom  string
    9.43 +	ExpiresIn    int32
    9.44 +	TokenType    string
    9.45 +	Scopes       Scopes
    9.46 +	ProfileID    uuid.ID
    9.47 +	ClientID     uuid.ID
    9.48 +	Revoked      bool
    9.49  }
    9.50  
    9.51 +func (t Token) GenerateAccessToken(privateKey []byte) (string, error) {
    9.52 +	access := jwt.New(jwt.SigningMethodHS256)
    9.53 +	access.Claims["iss"] = t.ClientID
    9.54 +	access.Claims["sub"] = t.ProfileID
    9.55 +	access.Claims["exp"] = t.Created.Add(defaultTokenExpiration * time.Second).Unix()
    9.56 +	access.Claims["nbf"] = t.Created.Add(-2 * time.Minute).Unix()
    9.57 +	access.Claims["iat"] = t.Created.Unix()
    9.58 +	access.Claims["scope"] = strings.Join(t.Scopes.Strings(), " ")
    9.59 +	return access.SignedString(privateKey)
    9.60 +}
    9.61 +
    9.62 +// BUG(paddy): Now that access tokens are generated and have a meaning, refresh tokens should be the primary key
    9.63 +
    9.64  type tokenStore interface {
    9.65  	getToken(token string, refresh bool) (Token, error)
    9.66  	saveToken(token Token) error
    9.67 -	revokeToken(token string, refresh bool) error
    9.68 +	revokeToken(token string) error
    9.69  	getTokensByProfileID(profileID uuid.ID, num, offset int) ([]Token, error)
    9.70  	revokeTokensByProfileID(profileID uuid.ID) error
    9.71  	revokeTokensByClientID(clientID uuid.ID) error
    9.72 @@ -96,13 +110,10 @@
    9.73  	return nil
    9.74  }
    9.75  
    9.76 -func (m *memstore) revokeToken(token string, refresh bool) error {
    9.77 -	if refresh {
    9.78 -		t, err := m.lookupTokenByRefresh(token)
    9.79 -		if err != nil {
    9.80 -			return err
    9.81 -		}
    9.82 -		token = t
    9.83 +func (m *memstore) revokeToken(token string) error {
    9.84 +	token, err := m.lookupTokenByRefresh(token)
    9.85 +	if err != nil {
    9.86 +		return err
    9.87  	}
    9.88  	m.tokenLock.Lock()
    9.89  	defer m.tokenLock.Unlock()
    9.90 @@ -110,11 +121,7 @@
    9.91  	if !ok {
    9.92  		return ErrTokenNotFound
    9.93  	}
    9.94 -	if refresh {
    9.95 -		t.RefreshRevoked = true
    9.96 -	} else {
    9.97 -		t.Revoked = true
    9.98 -	}
    9.99 +	t.Revoked = true
   9.100  	m.tokens[token] = t
   9.101  	return nil
   9.102  }
   9.103 @@ -132,7 +139,6 @@
   9.104  	for _, id := range ids {
   9.105  		token := m.tokens[id]
   9.106  		token.Revoked = true
   9.107 -		token.RefreshRevoked = true
   9.108  		m.tokens[id] = token
   9.109  	}
   9.110  	return nil
   9.111 @@ -146,7 +152,6 @@
   9.112  			continue
   9.113  		}
   9.114  		token.Revoked = true
   9.115 -		token.RefreshRevoked = true
   9.116  		m.tokens[id] = token
   9.117  	}
   9.118  	return nil
   9.119 @@ -204,7 +209,7 @@
   9.120  		renderJSONError(enc, "invalid_grant")
   9.121  		return
   9.122  	}
   9.123 -	if token.RefreshRevoked {
   9.124 +	if token.Revoked {
   9.125  		w.WriteHeader(http.StatusBadRequest)
   9.126  		renderJSONError(enc, "invalid_grant")
   9.127  		return
   9.128 @@ -217,51 +222,9 @@
   9.129  	if refresh == "" {
   9.130  		return ErrTokenNotFound
   9.131  	}
   9.132 -	return context.RevokeToken(refresh, true)
   9.133 +	return context.RevokeToken(refresh)
   9.134  }
   9.135  
   9.136  func refreshTokenAuditString(r *http.Request) string {
   9.137  	return "refresh_token:" + r.PostFormValue("refresh_token")
   9.138  }
   9.139 -
   9.140 -func RegisterTokenHandlers(r *mux.Router, context Context) {
   9.141 -	r.Handle("/tokens/{id}", wrap(context, GetTokenInfoHandler)).Methods("GET", "OPTIONS")
   9.142 -	r.Handle("/tokens/{id}", wrap(context, RevokeTokenHandler)).Methods("DELETE", "OPTIONS")
   9.143 -}
   9.144 -
   9.145 -// GetTokenInfoHandler is an HTTP handler for retrieving information about a token.
   9.146 -func GetTokenInfoHandler(w http.ResponseWriter, r *http.Request, context Context) {
   9.147 -	errors := []requestError{}
   9.148 -	vars := mux.Vars(r)
   9.149 -	tokenID := vars["id"]
   9.150 -	if tokenID == "" {
   9.151 -		errors = append(errors, requestError{Slug: requestErrMissing, Param: "id"})
   9.152 -		encode(w, r, http.StatusBadRequest, response{Errors: errors})
   9.153 -		return
   9.154 -	}
   9.155 -	token, err := context.GetToken(tokenID, false)
   9.156 -	if err != nil {
   9.157 -		if err == ErrTokenNotFound {
   9.158 -			errors = append(errors, requestError{Slug: requestErrNotFound, Param: "id"})
   9.159 -			encode(w, r, http.StatusNotFound, response{Errors: errors})
   9.160 -			return
   9.161 -		}
   9.162 -		encode(w, r, http.StatusInternalServerError, actOfGodResponse)
   9.163 -		return
   9.164 -	}
   9.165 -	token.RefreshToken = ""
   9.166 -	expired := int64(time.Now().Sub(token.Created) / time.Second)
   9.167 -	if expired > int64(token.ExpiresIn) {
   9.168 -		token.ExpiresIn = 0
   9.169 -	} else {
   9.170 -		token.ExpiresIn = token.ExpiresIn - int32(expired)
   9.171 -	}
   9.172 -	encode(w, r, http.StatusOK, response{Tokens: []Token{token}})
   9.173 -	return
   9.174 -}
   9.175 -
   9.176 -// RevokeTokenHandler is an HTTP handler for revoking a Token prematurely.
   9.177 -func RevokeTokenHandler(w http.ResponseWriter, r *http.Request, context Context) {
   9.178 -	//errors := []requestError{}
   9.179 -	// TODO
   9.180 -}
    10.1 --- a/token_postgres.go	Tue May 12 21:14:21 2015 -0400
    10.2 +++ b/token_postgres.go	Fri May 15 19:44:40 2015 -0400
    10.3 @@ -69,21 +69,17 @@
    10.4  	return err
    10.5  }
    10.6  
    10.7 -func (p *postgres) revokeTokenSQL(token string, refresh bool) *pan.Query {
    10.8 +func (p *postgres) revokeTokenSQL(token string) *pan.Query {
    10.9  	var t Token
   10.10  	query := pan.New(pan.POSTGRES, "UPDATE "+pan.GetTableName(t)+" SET ")
   10.11  	query.Include(pan.GetUnquotedColumn(t, "Revoked")+" = ?", true)
   10.12  	query.IncludeWhere()
   10.13 -	if !refresh {
   10.14 -		query.Include(pan.GetUnquotedColumn(t, "AccessToken")+" = ?", token)
   10.15 -	} else {
   10.16 -		query.Include(pan.GetUnquotedColumn(t, "RefreshToken")+" = ?", token)
   10.17 -	}
   10.18 +	query.Include(pan.GetUnquotedColumn(t, "RefreshToken")+" = ?", token)
   10.19  	return query.FlushExpressions(" ")
   10.20  }
   10.21  
   10.22 -func (p *postgres) revokeToken(token string, refresh bool) error {
   10.23 -	query := p.revokeTokenSQL(token, refresh)
   10.24 +func (p *postgres) revokeToken(token string) error {
   10.25 +	query := p.revokeTokenSQL(token)
   10.26  	res, err := p.db.Exec(query.String(), query.Args...)
   10.27  	if err != nil {
   10.28  		return err
    11.1 --- a/token_test.go	Tue May 12 21:14:21 2015 -0400
    11.2 +++ b/token_test.go	Fri May 15 19:44:40 2015 -0400
    11.3 @@ -81,11 +81,7 @@
    11.4  		} else if err != ErrTokenNotFound {
    11.5  			t.Errorf("Expected ErrTokenNotFound from %T, got %s", store, err)
    11.6  		}
    11.7 -		err = context.RevokeToken(token.AccessToken, false)
    11.8 -		if err != ErrTokenNotFound {
    11.9 -			t.Errorf("Expected ErrTokenNotFound from %T, got %s", store, err)
   11.10 -		}
   11.11 -		err = context.RevokeToken(token.RefreshToken, true)
   11.12 +		err = context.RevokeToken(token.RefreshToken)
   11.13  		if err != ErrTokenNotFound {
   11.14  			t.Errorf("Expected ErrTokenNotFound from %T, got %s", store, err)
   11.15  		}
   11.16 @@ -124,7 +120,7 @@
   11.17  		if !success {
   11.18  			t.Errorf("Expected field %s to be %v, but got %v from %T", field, expectation, result, store)
   11.19  		}
   11.20 -		err = context.RevokeToken(token.AccessToken, false)
   11.21 +		err = context.RevokeToken(token.RefreshToken)
   11.22  		if err != nil {
   11.23  			t.Errorf("Error revoking token in %T: %s", store, err)
   11.24  		}
   11.25 @@ -137,19 +133,6 @@
   11.26  		if !success {
   11.27  			t.Errorf("Expected field %s to be %v, but got %v from %T", field, expectation, result, store)
   11.28  		}
   11.29 -		err = context.RevokeToken(token.RefreshToken, true)
   11.30 -		if err != nil {
   11.31 -			t.Errorf("Error revoking token in %T: %s", store, err)
   11.32 -		}
   11.33 -		retrievedRevoked, err = context.GetToken(token.RefreshToken, true)
   11.34 -		if err != nil {
   11.35 -			t.Errorf("Error retrieving token from %T: %s", store, err)
   11.36 -		}
   11.37 -		token.RefreshRevoked = true
   11.38 -		success, field, expectation, result = compareTokens(token, retrievedRevoked)
   11.39 -		if !success {
   11.40 -			t.Errorf("Expected field %s to be %v, but got %v from %T", field, expectation, result, store)
   11.41 -		}
   11.42  	}
   11.43  }
   11.44