From 3a1e06704c7fb4c1ec31cbfeb861b66032796f34 Mon Sep 17 00:00:00 2001 From: Benjamin Bengfort Date: Thu, 19 Jan 2023 13:47:20 -0600 Subject: [PATCH] Integrate AuthN and AuthZ into Quarterdeck (#93) --- pkg/quarterdeck/api/v1/client.go | 10 +++++++++- pkg/quarterdeck/api/v1/creds.go | 20 ++++++++++++++++++++ pkg/quarterdeck/api/v1/errors.go | 2 ++ pkg/quarterdeck/api/v1/options.go | 24 ++++++++++++++++++++++++ pkg/quarterdeck/apikeys_test.go | 10 ++++++---- pkg/quarterdeck/quarterdeck.go | 23 ++++++++++++++--------- 6 files changed, 75 insertions(+), 14 deletions(-) create mode 100644 pkg/quarterdeck/api/v1/creds.go diff --git a/pkg/quarterdeck/api/v1/client.go b/pkg/quarterdeck/api/v1/client.go index 510b89cc4..400d0f92c 100644 --- a/pkg/quarterdeck/api/v1/client.go +++ b/pkg/quarterdeck/api/v1/client.go @@ -50,6 +50,7 @@ func New(endpoint string, opts ...ClientOption) (_ QuarterdeckClient, err error) type APIv1 struct { endpoint *url.URL // the base url for all requests client *http.Client // used to make http requests to the server + creds Credentials // default credentials used to authorize requests } // Ensure the APIv1 implements the QuarterdeckClient interface @@ -265,7 +266,14 @@ func (s *APIv1) NewRequest(ctx context.Context, method, path string, data interf req.Header.Add("Accept-Encoding", acceptEncode) req.Header.Add("Content-Type", contentType) - // TODO: add authentication if its available (add Authorization header) + // add authentication if its available (add Authorization header) + if s.creds != nil { + var token string + if token, err = s.creds.AccessToken(); err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + } // Add CSRF protection if its available if s.client.Jar != nil { diff --git a/pkg/quarterdeck/api/v1/creds.go b/pkg/quarterdeck/api/v1/creds.go new file mode 100644 index 000000000..2f0aaf252 --- /dev/null +++ b/pkg/quarterdeck/api/v1/creds.go @@ -0,0 +1,20 @@ +package api + +// Credentials provides a basic interface for loading an access token from Quarterdeck +// into the Quarterdeck API client. Credentials can be loaded from disk, generated, or +// feched from a passthrough request. +type Credentials interface { + AccessToken() (string, error) +} + +// A Token is just the JWT base64 encoded token string that is obtained from +// Quarterdeck either using the authtest server or from a login with the client. +type Token string + +// Token implements the credentials interface and performs limited validation. +func (t Token) AccessToken() (string, error) { + if string(t) == "" { + return "", ErrInvalidCredentials + } + return string(t), nil +} diff --git a/pkg/quarterdeck/api/v1/errors.go b/pkg/quarterdeck/api/v1/errors.go index 7db415733..f64204816 100644 --- a/pkg/quarterdeck/api/v1/errors.go +++ b/pkg/quarterdeck/api/v1/errors.go @@ -19,6 +19,8 @@ var ( ErrMissingRegisterField = errors.New("name and email address are required") ErrPasswordMismatch = errors.New("passwords do not match") ErrPasswordTooWeak = errors.New("password is too weak: use a combination of upper and lower case letters, numbers, and special characters") + ErrInvalidCredentials = errors.New("quarterdeck credentials are missing or invalid") + ErrExpiredCredentials = errors.New("quarterdeck credentials have expired") ) // Construct a new response for an error or simply return unsuccessful. diff --git a/pkg/quarterdeck/api/v1/options.go b/pkg/quarterdeck/api/v1/options.go index a84da0551..4fabfdd93 100644 --- a/pkg/quarterdeck/api/v1/options.go +++ b/pkg/quarterdeck/api/v1/options.go @@ -13,3 +13,27 @@ func WithClient(client *http.Client) ClientOption { return nil } } + +func WithCredentials(creds Credentials) ClientOption { + return func(c *APIv1) error { + c.creds = creds + return nil + } +} + +// RequestOption allows us to configure individual APIv1 client requests +// TODO: this is just a hack that modifies the request to get us started, but we will +// likely want to consider what design pattern we want to use in SC-12797. +type RequestOption func(req *http.Request) error + +// WithRPCCredentials overwrites any existing Authorize header with the specified creds. +func WithRPCCredentials(creds Credentials) RequestOption { + return func(req *http.Request) (err error) { + var token string + if token, err = creds.AccessToken(); err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + return nil + } +} diff --git a/pkg/quarterdeck/apikeys_test.go b/pkg/quarterdeck/apikeys_test.go index b349e4ffb..5b7029c58 100644 --- a/pkg/quarterdeck/apikeys_test.go +++ b/pkg/quarterdeck/apikeys_test.go @@ -14,10 +14,12 @@ func (suite *quarterdeckTestSuite) TestAPIKeyList() { // TODO: implement actual tests req := &api.PageQuery{} - rep, err := suite.client.APIKeyList(ctx, req) - require.NoError(err, "should return an empty list") - require.Empty(rep.APIKeys) - require.Empty(rep.NextPageToken) + _, err := suite.client.APIKeyList(ctx, req) + require.Error(err, "unauthorized requests should not return a response") + + // require.NoError(err, "should return an empty list") + // require.Empty(rep.APIKeys) + // require.Empty(rep.NextPageToken) } func (suite *quarterdeckTestSuite) TestAPIKeyCreate() { diff --git a/pkg/quarterdeck/quarterdeck.go b/pkg/quarterdeck/quarterdeck.go index 7f4d91599..8ce20fd51 100644 --- a/pkg/quarterdeck/quarterdeck.go +++ b/pkg/quarterdeck/quarterdeck.go @@ -18,6 +18,7 @@ import ( "github.com/rotationalio/ensign/pkg/quarterdeck/api/v1" "github.com/rotationalio/ensign/pkg/quarterdeck/config" "github.com/rotationalio/ensign/pkg/quarterdeck/db" + "github.com/rotationalio/ensign/pkg/quarterdeck/middleware" "github.com/rotationalio/ensign/pkg/quarterdeck/tokens" "github.com/rotationalio/ensign/pkg/utils/logger" "github.com/rotationalio/ensign/pkg/utils/sentry" @@ -177,7 +178,7 @@ func (s *Server) Shutdown() (err error) { } // Setup the server's middleware and routes (done once in New). -func (s *Server) setupRoutes() error { +func (s *Server) setupRoutes() (err error) { // Instantiate Sentry Handlers var tags gin.HandlerFunc if s.conf.Sentry.UseSentry() { @@ -239,6 +240,12 @@ func (s *Server) setupRoutes() error { } } + // Instantiate per-route middleware. + var authenticate gin.HandlerFunc + if authenticate, err = middleware.Authenticate(middleware.WithValidator(s.tokens)); err != nil { + return err + } + // Add the v1 API routes v1 := s.router.Group("/v1") { @@ -249,18 +256,16 @@ func (s *Server) setupRoutes() error { v1.POST("/register", s.Register) v1.POST("/login", s.Login) v1.POST("/authenticate", s.Authenticate) - - // Authenticated access routes v1.POST("/refresh", s.Refresh) // API Keys Resource - apikeys := v1.Group("/apikeys") + apikeys := v1.Group("/apikeys", authenticate) { - apikeys.GET("", s.APIKeyList) - apikeys.POST("", s.APIKeyCreate) - apikeys.GET("/:id", s.APIKeyDetail) - apikeys.PUT("/:id", s.APIKeyUpdate) - apikeys.DELETE("/:id", s.APIKeyDelete) + apikeys.GET("", middleware.Authorize("apikeys:read"), s.APIKeyList) + apikeys.POST("", middleware.Authorize("apikeys:edit"), s.APIKeyCreate) + apikeys.GET("/:id", middleware.Authorize("apikeys:read"), s.APIKeyDetail) + apikeys.PUT("/:id", middleware.Authorize("apikeys:edit"), s.APIKeyUpdate) + apikeys.DELETE("/:id", middleware.Authorize("apikeys:delete"), s.APIKeyDelete) } }