From 025c7067e4f8732a0168135807ad065f4303dfe7 Mon Sep 17 00:00:00 2001 From: Geoffrey Wossum Date: Wed, 12 Apr 2023 10:20:07 -0500 Subject: [PATCH] feat: add JWT meta API authentication (#2754) * feat: add JWT meta API authentication Add JWT meta API authentication using the new `[auth] meta-internal-shared-secret` configuration parameter. closes: #2753 --- etc/kapacitor/kapacitor.conf | 12 ++ go.mod | 2 + go.sum | 5 + integrations/metaauth_test.go | 238 ++++++++++++++++++++++++++++++++++ services/auth/config.go | 23 ++-- services/auth/meta/client.go | 8 ++ services/auth/service.go | 35 ++++- 7 files changed, 308 insertions(+), 15 deletions(-) create mode 100644 integrations/metaauth_test.go diff --git a/etc/kapacitor/kapacitor.conf b/etc/kapacitor/kapacitor.conf index f8839787d..531dace82 100644 --- a/etc/kapacitor/kapacitor.conf +++ b/etc/kapacitor/kapacitor.conf @@ -27,6 +27,18 @@ default-retention-policy = "" # host:port meta-addr = "172.17.0.2:8091" meta-use-tls = false + + # Username for basic user authorization when using meta API. meta-password should also be set. + # meta-username = "kapauser" + + # Password for basic user authorization when using meta API. meta-username must also be set. + # meta-password = "kapapass" + + # Shared secret for JWT bearer token authentication when using meta API. + # If this is set, then the `meta-username` and `meta-password` settings are ignored. + # This should match the `[meta] internal-shared-secret` setting on the meta nodes. + # meta-internal-shared-secret = "MyVoiceIsMyPassport" + # Absolute path to PEM encoded Certificate Authority (CA) file. # A CA can be provided without a key/certificate pair. meta-ca = "/etc/kapacitor/ca.pem" diff --git a/go.mod b/go.mod index af008e377..6ebd03706 100644 --- a/go.mod +++ b/go.mod @@ -143,6 +143,8 @@ require ( github.com/googleapis/gax-go/v2 v2.0.5 // indirect github.com/googleapis/gnostic v0.4.1 // indirect github.com/gophercloud/gophercloud v0.17.0 // indirect + github.com/h2non/gock v1.2.0 // indirect + github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect github.com/hashicorp/consul/api v1.8.1 // indirect github.com/hashicorp/errwrap v1.0.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.1 // indirect diff --git a/go.sum b/go.sum index b99c177e1..872785871 100644 --- a/go.sum +++ b/go.sum @@ -692,6 +692,10 @@ github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgf github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE= +github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/hashicorp/consul/api v1.3.0/go.mod h1:MmDNSzIMUjNpY/mQ398R4bk2FnqQLoPndWW5VkKPlCE= github.com/hashicorp/consul/api v1.8.1 h1:BOEQaMWoGMhmQ29fC26bi0qb7/rId9JzZP2V0Xmx7m8= github.com/hashicorp/consul/api v1.8.1/go.mod h1:sDjTOq0yUyv5G4h+BqSea7Fn6BU+XbolEz1952UB+mk= @@ -1034,6 +1038,7 @@ github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxzi github.com/nats-io/nuid v1.0.0/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= diff --git a/integrations/metaauth_test.go b/integrations/metaauth_test.go new file mode 100644 index 000000000..80712fe9c --- /dev/null +++ b/integrations/metaauth_test.go @@ -0,0 +1,238 @@ +package integrations + +import ( + "errors" + "fmt" + "net/http" + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt" + "github.com/h2non/gock" + "golang.org/x/crypto/bcrypt" + + authcore "github.com/influxdata/kapacitor/auth" + "github.com/influxdata/kapacitor/keyvalue" + "github.com/influxdata/kapacitor/services/auth" + "github.com/influxdata/kapacitor/services/auth/meta" + "github.com/influxdata/kapacitor/services/storage" + "github.com/stretchr/testify/require" +) + +type NopDiag struct{} + +func (d *NopDiag) Debug(msg string, ctx ...keyvalue.T) {} + +type NopStorageService struct{} + +func (s *NopStorageService) Store(namespace string) storage.Interface { + return nil +} + +// newTestAuthService makes an auth service with given config hooked up for mocking with gock. +func newTestAuthService(config auth.Config) (*auth.Service, error) { + diag := &NopDiag{} + interceptClient := func(c *http.Client) error { gock.InterceptClient(c); return nil } + srv, err := auth.NewService(config, diag, meta.WithHTTPOption(interceptClient)) + if err != nil { + return nil, err + } + if srv == nil { + return nil, fmt.Errorf("auth.NewService returned nil without an error") + } + + srv.StorageService = &NopStorageService{} + srv.HTTPDService = newHTTPDService() + if err = srv.Open(); err != nil { + return nil, err + } + return srv, nil +} + +const ( + metaName = "meta1.edge" + metaPort = 8091 + + metaSecret = "MyVoiceIsMyPassport" + metaUser = "JoeyJo-JoJuniorShabadoo" + metaPass = "ShabadooPassword" +) + +var ( + metaAddr = fmt.Sprintf("%s:%d", metaName, metaPort) + metaUrl = fmt.Sprintf("http://%s", metaAddr) +) + +// bearerCheck is a gock matcher that ensures the bearer token presented by the client is correct. +func bearerCheck(user, secret string) gock.MatchFunc { + return func(r *http.Request, gr *gock.Request) (bool, error) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + return false, nil + } + authSections := strings.Split(authHeader, " ") + if len(authSections) != 2 || authSections[0] != "Bearer" { + return false, nil + } + tokenStr := authSections[1] + token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("signing method should be HMAC") + } + return []byte(secret), nil + }) + if err != nil { + return false, err + } + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return false, errors.New("improper claims object") + } + claimsUser, ok := claims["username"].(string) + if !ok { + return false, errors.New("bad claims username") + } + if claimsUser != user { + return false, nil + } + return claims.VerifyExpiresAt(time.Now().Unix(), true), nil + } +} + +// runCommonMetaAuthTests runs common test cases that require using the meta API +// to authenticate kapacitor users. +func runCommonMetaAuthTests(t *testing.T, config auth.Config, authType meta.AuthType) { + defer gock.OffAll() + gock.Observe(gock.DumpRequest) + + // newGock creates a gock request configured for the expected type of authentication. + newGock := func() *gock.Request { + gr := gock.New(metaUrl).SetMatcher(gock.NewMatcher()) + switch authType { + case meta.BasicAuth: + gr.BasicAuth(metaUser, metaPass) + case meta.BearerAuth: + gr.MatchHeader("Authorization", "Bearer (.*)") + // When using the internal shared secret the username should be empty + gr.AddMatcher(bearerCheck("", metaSecret)) + } + return gr + } + + type UsersJson struct { + Users []meta.User `json:"users"` + } + passwordHash := func(pass string) string { + hash, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost) + require.NoError(t, err) + return string(hash) + } + + metaAlice := meta.User{ + Name: "alice", + Hash: passwordHash("CaptainPicard"), + Permissions: map[string][]meta.Permission{"ProjectScorpio": {meta.Permission(meta.KapacitorAPIPermission)}}, + } + authAlice := authcore.NewUser("alice", []byte(metaAlice.Hash), false, map[string][]authcore.Privilege{"/api": {authcore.AllPrivileges}, "/api/config": {authcore.NoPrivileges}}) + + metaBob := meta.User{ + Name: "bob", + Hash: passwordHash("TheDoctor"), + Permissions: map[string][]meta.Permission{"ProjectScorpio": {meta.Permission(meta.ReadDataPermission)}}, + } + authBob := authcore.NewUser("bob", []byte(metaBob.Hash), false, map[string][]authcore.Privilege{"/api/ping": {authcore.AllPrivileges}, "/database/ProjectScorpio_clean": {authcore.ReadPrivilege}}) + + authBad := authcore.User{} + + metaUsers := map[string]meta.User{ + "alice": metaAlice, + "bob": metaBob, + } + addValidUserReq := func(name string) { + newGock().Get("/user"). + MatchParam("name", name). + Reply(200). + JSON(UsersJson{Users: []meta.User{metaUsers[name]}}) + + } + + addValidUserReq("alice") // first request with invalid user password + addValidUserReq("alice") // second request with valid user password + addValidUserReq("bob") + + // add an invalid username request + newGock().Get("/user"). + MatchParam("name", "carol"). + Reply(404) + + srv, err := newTestAuthService(config) + require.NoError(t, err) + require.NotNil(t, srv) + + // check for failure with bad alice password + alice, err := srv.Authenticate("alice", "CaptainKirk") + require.Error(t, err) + require.Equal(t, authBad, alice) + + alice, err = srv.Authenticate("alice", "CaptainPicard") + require.NoError(t, err) + require.Equal(t, authAlice, alice) + + // This should be cached not require a request to the meta API, yet it does... + /* + alice, err = srv.Authenticate("alice", "CaptainPicard") + require.NoError(t, err) + require.Equal(t, authAlice, alice) + */ + + bob, err := srv.Authenticate("bob", "TheDoctor") + require.NoError(t, err) + require.Equal(t, authBob, bob) + + carol, err := srv.Authenticate("carol", "LukeSkywalker") + require.Error(t, err) + require.Equal(t, authBad, carol) + + require.True(t, gock.IsDone()) +} + +func TestMetaAuth_NoAuth(t *testing.T) { + config := auth.Config{ + Enabled: true, + MetaAddr: metaAddr, + } + runCommonMetaAuthTests(t, config, meta.NoAuth) +} + +func TestMetaAuth_UserPass(t *testing.T) { + config := auth.Config{ + Enabled: true, + MetaAddr: metaAddr, + MetaUsername: metaUser, + MetaPassword: metaPass, + } + runCommonMetaAuthTests(t, config, meta.BasicAuth) +} + +func TestMetaAuth_Secret(t *testing.T) { + config := auth.Config{ + Enabled: true, + MetaAddr: metaAddr, + MetaInternalSharedSecret: metaSecret, + } + runCommonMetaAuthTests(t, config, meta.BearerAuth) +} + +func TestMetaAuth_SecretAndUserPass(t *testing.T) { + config := auth.Config{ + Enabled: true, + MetaAddr: metaAddr, + MetaInternalSharedSecret: metaSecret, + + // MetaUsername and MetaPassword should be ignored if MetaInternalSharedSecret is set. + MetaUsername: metaUser, + MetaPassword: metaPass, + } + runCommonMetaAuthTests(t, config, meta.BearerAuth) +} diff --git a/services/auth/config.go b/services/auth/config.go index b16a6b688..e9c3a3039 100644 --- a/services/auth/config.go +++ b/services/auth/config.go @@ -15,17 +15,18 @@ const ( ) type Config struct { - Enabled bool `toml:"enabled"` - CacheExpiration toml.Duration `toml:"cache-expiration"` - BcryptCost int `toml:"bcrypt-cost"` - MetaAddr string `toml:"meta-addr"` - MetaUsername string `toml:"meta-username"` - MetaPassword string `toml:"meta-password"` - MetaUseTLS bool `toml:"meta-use-tls"` - MetaCA string `toml:"meta-ca"` - MetaCert string `toml:"meta-cert"` - MetaKey string `toml:"meta-key"` - MetaInsecureSkipVerify bool `toml:"meta-insecure-skip-verify"` + Enabled bool `toml:"enabled"` + CacheExpiration toml.Duration `toml:"cache-expiration"` + BcryptCost int `toml:"bcrypt-cost"` + MetaAddr string `toml:"meta-addr"` + MetaUsername string `toml:"meta-username"` + MetaPassword string `toml:"meta-password"` + MetaInternalSharedSecret string `toml:"meta-internal-shared-secret"` + MetaUseTLS bool `toml:"meta-use-tls"` + MetaCA string `toml:"meta-ca"` + MetaCert string `toml:"meta-cert"` + MetaKey string `toml:"meta-key"` + MetaInsecureSkipVerify bool `toml:"meta-insecure-skip-verify"` } func NewDisabledConfig() Config { diff --git a/services/auth/meta/client.go b/services/auth/meta/client.go index c6cc16006..c02f3e213 100644 --- a/services/auth/meta/client.go +++ b/services/auth/meta/client.go @@ -119,6 +119,14 @@ var WithTimeout = func(d time.Duration) ClientOption { } } +type ClientHTTPOption func(client *http.Client) error + +func WithHTTPOption(opt ClientHTTPOption) ClientOption { + return func(c *Client) { + opt(c.client) + } +} + // NewClient returns a new Client, which will make requests to the Meta // node listening on addr. New accepts zero or more functional options // for configuring aspects of the returned Client. diff --git a/services/auth/service.go b/services/auth/service.go index 86dfa6ddc..371cb8cf4 100644 --- a/services/auth/service.go +++ b/services/auth/service.go @@ -72,7 +72,23 @@ type authCred struct { expires time.Time } -func NewService(c Config, d Diagnostic) (*Service, error) { +type ServiceOption func(*Service) error + +func NewService(c Config, d Diagnostic, opts ...interface{}) (*Service, error) { + // Separate the opts into meta.ClientOption and ServiceOption + var serviceOpts []ServiceOption + var metaClientOpts []meta.ClientOption + for _, abstractOpt := range opts { + switch opt := abstractOpt.(type) { + case ServiceOption: + serviceOpts = append(serviceOpts, opt) + case meta.ClientOption: + metaClientOpts = append(metaClientOpts, opt) + default: + return nil, fmt.Errorf("NewService: unexpected opt type (%T)", opt) + } + } + var pmClient *meta.Client if c.MetaAddr != "" { tlsConfig, err := tlsconfig.Create(c.MetaCA, c.MetaCert, c.MetaKey, c.MetaInsecureSkipVerify) @@ -82,22 +98,33 @@ func NewService(c Config, d Diagnostic) (*Service, error) { pmOpts := []meta.ClientOption{ meta.WithTLS(tlsConfig, c.MetaUseTLS, c.MetaInsecureSkipVerify), } - if c.MetaUsername != "" { + if c.MetaInternalSharedSecret != "" { + pmOpts = append(pmOpts, meta.UseAuth(meta.BearerAuth, "", "", c.MetaInternalSharedSecret)) + } else if c.MetaUsername != "" { pmOpts = append(pmOpts, meta.UseAuth(meta.BasicAuth, c.MetaUsername, c.MetaPassword, "")) } + pmOpts = append(pmOpts, metaClientOpts...) //TODO: when the meta client can accept an interface, pass in a logger pmClient = meta.NewClient(c.MetaAddr, pmOpts...) } else { d.Debug("not using meta service for users, no address given") } - return &Service{ + srv := &Service{ diag: d, authCache: make(map[string]authCred), cacheExpiration: time.Duration(c.CacheExpiration), bcryptCost: c.BcryptCost, pmClient: pmClient, - }, nil + } + + for _, opt := range serviceOpts { + if err := opt(srv); err != nil { + return nil, err + } + } + + return srv, nil } const userNamespace = "user_store"