Repository: influxdata/kapacitor
Pull request #2757 (2023-04-12 20:25:56)

feat: add JWT meta API authentication
Add JWT meta API authentication using the new `[auth] meta-internal-shared-secret` configuration parameter.

Backport of #2754.

closes: #2755
(cherry picked from commit 025c7067e4f8732a0168135807ad065f4303dfe7)

### Required checklist
- [x] Sample config files updated (both `/etc` folder and `NewDemoConfig` methods) (influxdb and plutonium)


Patch:
diff --git a/etc/kapacitor/kapacitor.conf b/etc/kapacitor/kapacitor.conf
index f8839787..531dace8 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 ceea3396..e1a6e793 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 1d139fe8..eb605efb 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/services/auth/config.go b/services/auth/config.go
index b16a6b68..e9c3a303 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 c6cc1600..c02f3e21 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 86dfa6dd..371cb8cf 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"


Test patch:
diff --git a/integrations/metaauth_test.go b/integrations/metaauth_test.go
new file mode 100644
index 00000000..80712fe9
--- /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)
+}