Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add JWT meta API authentication #2757

Merged
merged 1 commit into from
Apr 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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