Skip to content

Commit

Permalink
feat: add JWT meta API authentication (#2754)
Browse files Browse the repository at this point in the history
* feat: add JWT meta API authentication

Add JWT meta API authentication using the new `[auth] meta-internal-shared-secret` configuration parameter.

closes: #2753
  • Loading branch information
gwossum authored Apr 12, 2023
1 parent 0985ae4 commit 025c706
Show file tree
Hide file tree
Showing 7 changed files with 308 additions and 15 deletions.
12 changes: 12 additions & 0 deletions etc/kapacitor/kapacitor.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
238 changes: 238 additions & 0 deletions integrations/metaauth_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
23 changes: 12 additions & 11 deletions services/auth/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions services/auth/meta/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
35 changes: 31 additions & 4 deletions services/auth/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"
Expand Down

0 comments on commit 025c706

Please sign in to comment.