diff --git a/.vscode.example/portainer.code-snippets b/.vscode.example/portainer.code-snippets index fa3511098..9c7716c7c 100644 --- a/.vscode.example/portainer.code-snippets +++ b/.vscode.example/portainer.code-snippets @@ -150,6 +150,7 @@ "// @description ", "// @description **Access policy**: ", "// @tags ", + "// @security ApiKeyAuth", "// @security jwt", "// @accept json", "// @produce json", diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c6e66f66d..62a331862 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -120,6 +120,7 @@ When adding a new route to an existing handler use the following as a template ( // @description // @description **Access policy**: // @tags +// @security ApiKeyAuth // @security jwt // @accept json // @produce json diff --git a/api/apikey/apikey.go b/api/apikey/apikey.go new file mode 100644 index 000000000..65fb3b3eb --- /dev/null +++ b/api/apikey/apikey.go @@ -0,0 +1,29 @@ +package apikey + +import ( + "crypto/rand" + "io" + + portainer "github.com/portainer/portainer/api" +) + +// APIKeyService represents a service for managing API keys. +type APIKeyService interface { + HashRaw(rawKey string) []byte + GenerateApiKey(user portainer.User, description string) (string, *portainer.APIKey, error) + GetAPIKeys(userID portainer.UserID) ([]portainer.APIKey, error) + GetDigestUserAndKey(digest []byte) (portainer.User, portainer.APIKey, error) + UpdateAPIKey(apiKey *portainer.APIKey) error + DeleteAPIKey(apiKeyID portainer.APIKeyID) error + InvalidateUserKeyCache(userId portainer.UserID) bool +} + +// generateRandomKey generates a random key of specified length +// source: https://github.com/gorilla/securecookie/blob/master/securecookie.go#L515 +func generateRandomKey(length int) []byte { + k := make([]byte, length) + if _, err := io.ReadFull(rand.Reader, k); err != nil { + return nil + } + return k +} diff --git a/api/apikey/apikey_test.go b/api/apikey/apikey_test.go new file mode 100644 index 000000000..dc154bab3 --- /dev/null +++ b/api/apikey/apikey_test.go @@ -0,0 +1,50 @@ +package apikey + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_generateRandomKey(t *testing.T) { + is := assert.New(t) + + tests := []struct { + name string + wantLenth int + }{ + { + name: "Generate a random key of length 16", + wantLenth: 16, + }, + { + name: "Generate a random key of length 32", + wantLenth: 32, + }, + { + name: "Generate a random key of length 64", + wantLenth: 64, + }, + { + name: "Generate a random key of length 128", + wantLenth: 128, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := generateRandomKey(tt.wantLenth) + is.Equal(tt.wantLenth, len(got)) + }) + } + + t.Run("Generated keys are unique", func(t *testing.T) { + keys := make(map[string]bool) + for i := 0; i < 100; i++ { + key := generateRandomKey(8) + _, ok := keys[string(key)] + is.False(ok) + keys[string(key)] = true + } + }) +} diff --git a/api/apikey/cache.go b/api/apikey/cache.go new file mode 100644 index 000000000..520f7274a --- /dev/null +++ b/api/apikey/cache.go @@ -0,0 +1,69 @@ +package apikey + +import ( + lru "github.com/hashicorp/golang-lru" + portainer "github.com/portainer/portainer/api" +) + +const defaultAPIKeyCacheSize = 1024 + +// entry is a tuple containing the user and API key associated to an API key digest +type entry struct { + user portainer.User + apiKey portainer.APIKey +} + +// apiKeyCache is a concurrency-safe, in-memory cache which primarily exists for to reduce database roundtrips. +// We store the api-key digest (keys) and the associated user and key-data (values) in the cache. +// This is required because HTTP requests will contain only the api-key digest in the x-api-key request header; +// digest value must be mapped to a portainer user (and respective key data) for validation. +// This cache is used to avoid multiple database queries to retrieve these user/key associated to the digest. +type apiKeyCache struct { + // cache type [string]entry cache (key: string(digest), value: user/key entry) + // note: []byte keys are not supported by golang-lru Cache + cache *lru.Cache +} + +// NewAPIKeyCache creates a new cache for API keys +func NewAPIKeyCache(cacheSize int) *apiKeyCache { + cache, _ := lru.New(cacheSize) + return &apiKeyCache{cache: cache} +} + +// Get returns the user/key associated to an api-key's digest +// This is required because HTTP requests will contain the digest of the API key in header, +// the digest value must be mapped to a portainer user. +func (c *apiKeyCache) Get(digest []byte) (portainer.User, portainer.APIKey, bool) { + val, ok := c.cache.Get(string(digest)) + if !ok { + return portainer.User{}, portainer.APIKey{}, false + } + tuple := val.(entry) + + return tuple.user, tuple.apiKey, true +} + +// Set persists a user/key entry to the cache +func (c *apiKeyCache) Set(digest []byte, user portainer.User, apiKey portainer.APIKey) { + c.cache.Add(string(digest), entry{ + user: user, + apiKey: apiKey, + }) +} + +// Delete evicts a digest's user/key entry key from the cache +func (c *apiKeyCache) Delete(digest []byte) { + c.cache.Remove(string(digest)) +} + +// InvalidateUserKeyCache loops through all the api-keys associated to a user and removes them from the cache +func (c *apiKeyCache) InvalidateUserKeyCache(userId portainer.UserID) bool { + present := false + for _, k := range c.cache.Keys() { + user, _, _ := c.Get([]byte(k.(string))) + if user.ID == userId { + present = c.cache.Remove(k) + } + } + return present +} diff --git a/api/apikey/cache_test.go b/api/apikey/cache_test.go new file mode 100644 index 000000000..71e1695d1 --- /dev/null +++ b/api/apikey/cache_test.go @@ -0,0 +1,181 @@ +package apikey + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/stretchr/testify/assert" +) + +func Test_apiKeyCacheGet(t *testing.T) { + is := assert.New(t) + + keyCache := NewAPIKeyCache(10) + + // pre-populate cache + keyCache.cache.Add(string("foo"), entry{user: portainer.User{}, apiKey: portainer.APIKey{}}) + keyCache.cache.Add(string(""), entry{user: portainer.User{}, apiKey: portainer.APIKey{}}) + + tests := []struct { + digest []byte + found bool + }{ + { + digest: []byte("foo"), + found: true, + }, + { + digest: []byte(""), + found: true, + }, + { + digest: []byte("bar"), + found: false, + }, + } + + for _, test := range tests { + t.Run(string(test.digest), func(t *testing.T) { + _, _, found := keyCache.Get(test.digest) + is.Equal(test.found, found) + }) + } +} + +func Test_apiKeyCacheSet(t *testing.T) { + is := assert.New(t) + + keyCache := NewAPIKeyCache(10) + + // pre-populate cache + keyCache.Set([]byte("bar"), portainer.User{ID: 2}, portainer.APIKey{}) + keyCache.Set([]byte("foo"), portainer.User{ID: 1}, portainer.APIKey{}) + + // overwrite existing entry + keyCache.Set([]byte("foo"), portainer.User{ID: 3}, portainer.APIKey{}) + + val, ok := keyCache.cache.Get(string("bar")) + is.True(ok) + + tuple := val.(entry) + is.Equal(portainer.User{ID: 2}, tuple.user) + + val, ok = keyCache.cache.Get(string("foo")) + is.True(ok) + + tuple = val.(entry) + is.Equal(portainer.User{ID: 3}, tuple.user) +} + +func Test_apiKeyCacheDelete(t *testing.T) { + is := assert.New(t) + + keyCache := NewAPIKeyCache(10) + + t.Run("Delete an existing entry", func(t *testing.T) { + keyCache.cache.Add(string("foo"), entry{user: portainer.User{ID: 1}, apiKey: portainer.APIKey{}}) + keyCache.Delete([]byte("foo")) + + _, ok := keyCache.cache.Get(string("foo")) + is.False(ok) + }) + + t.Run("Delete a non-existing entry", func(t *testing.T) { + nonPanicFunc := func() { keyCache.Delete([]byte("non-existent-key")) } + is.NotPanics(nonPanicFunc) + }) +} + +func Test_apiKeyCacheLRU(t *testing.T) { + is := assert.New(t) + + tests := []struct { + name string + cacheLen int + key []string + foundKeys []string + evictedKeys []string + }{ + { + name: "Cache length is 1, add 2 keys", + cacheLen: 1, + key: []string{"foo", "bar"}, + foundKeys: []string{"bar"}, + evictedKeys: []string{"foo"}, + }, + { + name: "Cache length is 1, add 3 keys", + cacheLen: 1, + key: []string{"foo", "bar", "baz"}, + foundKeys: []string{"baz"}, + evictedKeys: []string{"foo", "bar"}, + }, + { + name: "Cache length is 2, add 3 keys", + cacheLen: 2, + key: []string{"foo", "bar", "baz"}, + foundKeys: []string{"bar", "baz"}, + evictedKeys: []string{"foo"}, + }, + { + name: "Cache length is 2, add 4 keys", + cacheLen: 2, + key: []string{"foo", "bar", "baz", "qux"}, + foundKeys: []string{"baz", "qux"}, + evictedKeys: []string{"foo", "bar"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + keyCache := NewAPIKeyCache(test.cacheLen) + + for _, key := range test.key { + keyCache.Set([]byte(key), portainer.User{ID: 1}, portainer.APIKey{}) + } + + for _, key := range test.foundKeys { + _, _, found := keyCache.Get([]byte(key)) + is.True(found, "Key %s not found", key) + } + + for _, key := range test.evictedKeys { + _, _, found := keyCache.Get([]byte(key)) + is.False(found, "key %s should have been evicted", key) + } + }) + } +} + +func Test_apiKeyCacheInvalidateUserKeyCache(t *testing.T) { + is := assert.New(t) + + keyCache := NewAPIKeyCache(10) + + t.Run("Removes users keys from cache", func(t *testing.T) { + keyCache.cache.Add(string("foo"), entry{user: portainer.User{ID: 1}, apiKey: portainer.APIKey{}}) + + ok := keyCache.InvalidateUserKeyCache(1) + is.True(ok) + + _, ok = keyCache.cache.Get(string("foo")) + is.False(ok) + }) + + t.Run("Does not affect other keys", func(t *testing.T) { + keyCache.cache.Add(string("foo"), entry{user: portainer.User{ID: 1}, apiKey: portainer.APIKey{}}) + keyCache.cache.Add(string("bar"), entry{user: portainer.User{ID: 2}, apiKey: portainer.APIKey{}}) + + ok := keyCache.InvalidateUserKeyCache(1) + is.True(ok) + + ok = keyCache.InvalidateUserKeyCache(1) + is.False(ok) + + _, ok = keyCache.cache.Get(string("foo")) + is.False(ok) + + _, ok = keyCache.cache.Get(string("bar")) + is.True(ok) + }) +} diff --git a/api/apikey/service.go b/api/apikey/service.go new file mode 100644 index 000000000..d88fd1479 --- /dev/null +++ b/api/apikey/service.go @@ -0,0 +1,121 @@ +package apikey + +import ( + "crypto/sha256" + "encoding/base64" + "fmt" + "time" + + "github.com/pkg/errors" + + portainer "github.com/portainer/portainer/api" +) + +const portainerAPIKeyPrefix = "ptr_" + +var ErrInvalidAPIKey = errors.New("Invalid API key") + +type apiKeyService struct { + apiKeyRepository portainer.APIKeyRepository + userRepository portainer.UserService + cache *apiKeyCache +} + +func NewAPIKeyService(apiKeyRepository portainer.APIKeyRepository, userRepository portainer.UserService) *apiKeyService { + return &apiKeyService{ + apiKeyRepository: apiKeyRepository, + userRepository: userRepository, + cache: NewAPIKeyCache(defaultAPIKeyCacheSize), + } +} + +// HashRaw computes a hash digest of provided raw API key. +func (a *apiKeyService) HashRaw(rawKey string) []byte { + hashDigest := sha256.Sum256([]byte(rawKey)) + return hashDigest[:] +} + +// GenerateApiKey generates a raw API key for a user (for one-time display). +// The generated API key is stored in the cache and database. +func (a *apiKeyService) GenerateApiKey(user portainer.User, description string) (string, *portainer.APIKey, error) { + randKey := generateRandomKey(32) + encodedRawAPIKey := base64.StdEncoding.EncodeToString(randKey) + prefixedAPIKey := portainerAPIKeyPrefix + encodedRawAPIKey + + hashDigest := a.HashRaw(prefixedAPIKey) + + apiKey := &portainer.APIKey{ + UserID: user.ID, + Description: description, + Prefix: prefixedAPIKey[:7], + DateCreated: time.Now().Unix(), + Digest: hashDigest, + } + + err := a.apiKeyRepository.CreateAPIKey(apiKey) + if err != nil { + return "", nil, errors.Wrap(err, "Unable to create API key") + } + + // persist api-key to cache + a.cache.Set(apiKey.Digest, user, *apiKey) + + return prefixedAPIKey, apiKey, nil +} + +// GetAPIKeys returns all the API keys associated to a user. +func (a *apiKeyService) GetAPIKeys(userID portainer.UserID) ([]portainer.APIKey, error) { + return a.apiKeyRepository.GetAPIKeysByUserID(userID) +} + +// GetDigestUserAndKey returns the user and api-key associated to a specified hash digest. +// A cache lookup is performed first; if the user/api-key is not found in the cache, respective database lookups are performed. +func (a *apiKeyService) GetDigestUserAndKey(digest []byte) (portainer.User, portainer.APIKey, error) { + // get api key from cache if possible + cachedUser, cachedKey, ok := a.cache.Get(digest) + if ok { + return cachedUser, cachedKey, nil + } + + apiKey, err := a.apiKeyRepository.GetAPIKeyByDigest(digest) + if err != nil { + return portainer.User{}, portainer.APIKey{}, errors.Wrap(err, "Unable to retrieve API key") + } + + user, err := a.userRepository.User(apiKey.UserID) + if err != nil { + return portainer.User{}, portainer.APIKey{}, errors.Wrap(err, "Unable to retrieve digest user") + } + + // persist api-key to cache - for quicker future lookups + a.cache.Set(apiKey.Digest, *user, *apiKey) + + return *user, *apiKey, nil +} + +// UpdateAPIKey updates an API key and in cache and database. +func (a *apiKeyService) UpdateAPIKey(apiKey *portainer.APIKey) error { + user, _, err := a.GetDigestUserAndKey(apiKey.Digest) + if err != nil { + return errors.Wrap(err, "Unable to retrieve API key") + } + a.cache.Set(apiKey.Digest, user, *apiKey) + return a.apiKeyRepository.UpdateAPIKey(apiKey) +} + +// DeleteAPIKey deletes an API key and removes the digest/api-key entry from the cache. +func (a *apiKeyService) DeleteAPIKey(apiKeyID portainer.APIKeyID) error { + // get api-key digest to remove from cache + apiKey, err := a.apiKeyRepository.GetAPIKey(apiKeyID) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("Unable to retrieve API key: %d", apiKeyID)) + } + + // delete the user/api-key from cache + a.cache.Delete(apiKey.Digest) + return a.apiKeyRepository.DeleteAPIKey(apiKeyID) +} + +func (a *apiKeyService) InvalidateUserKeyCache(userId portainer.UserID) bool { + return a.cache.InvalidateUserKeyCache(userId) +} diff --git a/api/apikey/service_test.go b/api/apikey/service_test.go new file mode 100644 index 000000000..0e8db6cb0 --- /dev/null +++ b/api/apikey/service_test.go @@ -0,0 +1,289 @@ +package apikey + +import ( + "crypto/sha256" + "log" + "strings" + "testing" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt" + "github.com/stretchr/testify/assert" +) + +func Test_SatisfiesAPIKeyServiceInterface(t *testing.T) { + is := assert.New(t) + is.Implements((*APIKeyService)(nil), NewAPIKeyService(nil, nil)) +} + +func Test_GenerateApiKey(t *testing.T) { + is := assert.New(t) + + store, teardown := bolt.MustNewTestStore(true) + defer teardown() + + service := NewAPIKeyService(store.APIKeyRepository(), store.User()) + + t.Run("Successfully generates API key", func(t *testing.T) { + desc := "test-1" + rawKey, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, desc) + is.NoError(err) + is.NotEmpty(rawKey) + is.NotEmpty(apiKey) + is.Equal(desc, apiKey.Description) + }) + + t.Run("Api key prefix is 7 chars", func(t *testing.T) { + rawKey, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, "test-2") + is.NoError(err) + + is.Equal(rawKey[:7], apiKey.Prefix) + is.Len(apiKey.Prefix, 7) + }) + + t.Run("Api key has 'ptr_' as prefix", func(t *testing.T) { + rawKey, _, err := service.GenerateApiKey(portainer.User{ID: 1}, "test-x") + is.NoError(err) + + is.Equal(portainerAPIKeyPrefix, "ptr_") + is.True(strings.HasPrefix(rawKey, "ptr_")) + }) + + t.Run("Successfully caches API key", func(t *testing.T) { + user := portainer.User{ID: 1} + _, apiKey, err := service.GenerateApiKey(user, "test-3") + is.NoError(err) + + userFromCache, apiKeyFromCache, ok := service.cache.Get(apiKey.Digest) + is.True(ok) + is.Equal(user, userFromCache) + is.Equal(apiKey, &apiKeyFromCache) + }) + + t.Run("Decoded raw api-key digest matches generated digest", func(t *testing.T) { + rawKey, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, "test-4") + is.NoError(err) + + generatedDigest := sha256.Sum256([]byte(rawKey)) + + is.Equal(apiKey.Digest, generatedDigest[:]) + }) +} + +func Test_GetAPIKeys(t *testing.T) { + is := assert.New(t) + + store, teardown := bolt.MustNewTestStore(true) + defer teardown() + + service := NewAPIKeyService(store.APIKeyRepository(), store.User()) + + t.Run("Successfully returns all API keys", func(t *testing.T) { + user := portainer.User{ID: 1} + _, _, err := service.GenerateApiKey(user, "test-1") + is.NoError(err) + _, _, err = service.GenerateApiKey(user, "test-2") + is.NoError(err) + + keys, err := service.GetAPIKeys(user.ID) + is.NoError(err) + is.Len(keys, 2) + }) +} + +func Test_GetDigestUserAndKey(t *testing.T) { + is := assert.New(t) + + store, teardown := bolt.MustNewTestStore(true) + defer teardown() + + service := NewAPIKeyService(store.APIKeyRepository(), store.User()) + + t.Run("Successfully returns user and api key associated to digest", func(t *testing.T) { + user := portainer.User{ID: 1} + _, apiKey, err := service.GenerateApiKey(user, "test-1") + is.NoError(err) + + userGot, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest) + is.NoError(err) + is.Equal(user, userGot) + is.Equal(*apiKey, apiKeyGot) + }) + + t.Run("Successfully caches user and api key associated to digest", func(t *testing.T) { + user := portainer.User{ID: 1} + _, apiKey, err := service.GenerateApiKey(user, "test-1") + is.NoError(err) + + userGot, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest) + is.NoError(err) + is.Equal(user, userGot) + is.Equal(*apiKey, apiKeyGot) + + userFromCache, apiKeyFromCache, ok := service.cache.Get(apiKey.Digest) + is.True(ok) + is.Equal(userGot, userFromCache) + is.Equal(apiKeyGot, apiKeyFromCache) + }) +} + +func Test_UpdateAPIKey(t *testing.T) { + is := assert.New(t) + + store, teardown := bolt.MustNewTestStore(true) + defer teardown() + + service := NewAPIKeyService(store.APIKeyRepository(), store.User()) + + t.Run("Successfully updates the api-key LastUsed time", func(t *testing.T) { + user := portainer.User{ID: 1} + store.User().CreateUser(&user) + _, apiKey, err := service.GenerateApiKey(user, "test-x") + is.NoError(err) + + apiKey.LastUsed = time.Now().UTC().Unix() + err = service.UpdateAPIKey(apiKey) + is.NoError(err) + + _, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest) + is.NoError(err) + + log.Println(apiKey) + log.Println(apiKeyGot) + + is.Equal(apiKey.LastUsed, apiKeyGot.LastUsed) + + }) + + t.Run("Successfully updates api-key in cache upon api-key update", func(t *testing.T) { + _, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, "test-x2") + is.NoError(err) + + _, apiKeyFromCache, ok := service.cache.Get(apiKey.Digest) + is.True(ok) + is.Equal(*apiKey, apiKeyFromCache) + + apiKey.LastUsed = time.Now().UTC().Unix() + is.NotEqual(*apiKey, apiKeyFromCache) + + err = service.UpdateAPIKey(apiKey) + is.NoError(err) + + _, updatedAPIKeyFromCache, ok := service.cache.Get(apiKey.Digest) + is.True(ok) + is.Equal(*apiKey, updatedAPIKeyFromCache) + }) +} + +func Test_DeleteAPIKey(t *testing.T) { + is := assert.New(t) + + store, teardown := bolt.MustNewTestStore(true) + defer teardown() + + service := NewAPIKeyService(store.APIKeyRepository(), store.User()) + + t.Run("Successfully updates the api-key", func(t *testing.T) { + user := portainer.User{ID: 1} + _, apiKey, err := service.GenerateApiKey(user, "test-1") + is.NoError(err) + + _, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest) + is.NoError(err) + is.Equal(*apiKey, apiKeyGot) + + err = service.DeleteAPIKey(apiKey.ID) + is.NoError(err) + + _, _, err = service.GetDigestUserAndKey(apiKey.Digest) + is.Error(err) + }) + + t.Run("Successfully removes api-key from cache upon deletion", func(t *testing.T) { + user := portainer.User{ID: 1} + _, apiKey, err := service.GenerateApiKey(user, "test-1") + is.NoError(err) + + _, apiKeyFromCache, ok := service.cache.Get(apiKey.Digest) + is.True(ok) + is.Equal(*apiKey, apiKeyFromCache) + + err = service.DeleteAPIKey(apiKey.ID) + is.NoError(err) + + _, _, ok = service.cache.Get(apiKey.Digest) + is.False(ok) + }) +} + +func Test_InvalidateUserKeyCache(t *testing.T) { + is := assert.New(t) + + store, teardown := bolt.MustNewTestStore(true) + defer teardown() + + service := NewAPIKeyService(store.APIKeyRepository(), store.User()) + + t.Run("Successfully updates evicts keys from cache", func(t *testing.T) { + // generate api keys + user := portainer.User{ID: 1} + _, apiKey1, err := service.GenerateApiKey(user, "test-1") + is.NoError(err) + + _, apiKey2, err := service.GenerateApiKey(user, "test-2") + is.NoError(err) + + // verify api keys are present in cache + _, apiKeyFromCache, ok := service.cache.Get(apiKey1.Digest) + is.True(ok) + is.Equal(*apiKey1, apiKeyFromCache) + + _, apiKeyFromCache, ok = service.cache.Get(apiKey2.Digest) + is.True(ok) + is.Equal(*apiKey2, apiKeyFromCache) + + // evict cache + ok = service.InvalidateUserKeyCache(user.ID) + is.True(ok) + + // verify users keys have been flushed from cache + _, _, ok = service.cache.Get(apiKey1.Digest) + is.False(ok) + + _, _, ok = service.cache.Get(apiKey2.Digest) + is.False(ok) + }) + + t.Run("User key eviction does not affect other users keys", func(t *testing.T) { + // generate keys for 2 users + user1 := portainer.User{ID: 1} + _, apiKey1, err := service.GenerateApiKey(user1, "test-1") + is.NoError(err) + + user2 := portainer.User{ID: 2} + _, apiKey2, err := service.GenerateApiKey(user2, "test-2") + is.NoError(err) + + // verify keys in cache + _, apiKeyFromCache, ok := service.cache.Get(apiKey1.Digest) + is.True(ok) + is.Equal(*apiKey1, apiKeyFromCache) + + _, apiKeyFromCache, ok = service.cache.Get(apiKey2.Digest) + is.True(ok) + is.Equal(*apiKey2, apiKeyFromCache) + + // evict key of single user from cache + ok = service.cache.InvalidateUserKeyCache(user1.ID) + is.True(ok) + + // verify user1 key has been flushed from cache + _, _, ok = service.cache.Get(apiKey1.Digest) + is.False(ok) + + // verify user2 key is still in cache + _, _, ok = service.cache.Get(apiKey2.Digest) + is.True(ok) + }) +} diff --git a/api/bolt/apikeyrepository/apikeyrepository.go b/api/bolt/apikeyrepository/apikeyrepository.go new file mode 100644 index 000000000..0309e4f1e --- /dev/null +++ b/api/bolt/apikeyrepository/apikeyrepository.go @@ -0,0 +1,137 @@ +package apikeyrepository + +import ( + "bytes" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/bolt/internal" + + "github.com/boltdb/bolt" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "api_key" +) + +// Service represents a service for managing api-key data. +type Service struct { + connection *internal.DbConnection +} + +// NewService creates a new instance of a service. +func NewService(connection *internal.DbConnection) (*Service, error) { + err := internal.CreateBucket(connection, BucketName) + if err != nil { + return nil, err + } + + return &Service{ + connection: connection, + }, nil +} + +// GetAPIKeysByUserID returns a slice containing all the APIKeys a user has access to. +func (service *Service) GetAPIKeysByUserID(userID portainer.UserID) ([]portainer.APIKey, error) { + var result = make([]portainer.APIKey, 0) + + err := service.connection.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var record portainer.APIKey + err := internal.UnmarshalObject(v, &record) + if err != nil { + return err + } + + if record.UserID == userID { + result = append(result, record) + } + } + return nil + }) + + return result, err +} + +// GetAPIKeyByDigest returns the API key for the associated digest. +// Note: there is a 1-to-1 mapping of api-key and digest +func (service *Service) GetAPIKeyByDigest(digest []byte) (*portainer.APIKey, error) { + var result portainer.APIKey + + err := service.connection.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var record portainer.APIKey + err := internal.UnmarshalObject(v, &record) + if err != nil { + return err + } + + if bytes.Equal(record.Digest, digest) { + result = record + return nil + } + } + return nil + }) + + return &result, err +} + +// CreateAPIKey creates a new APIKey object. +func (service *Service) CreateAPIKey(record *portainer.APIKey) error { + return service.connection.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + id, _ := bucket.NextSequence() + record.ID = portainer.APIKeyID(id) + + data, err := internal.MarshalObject(record) + if err != nil { + return err + } + + return bucket.Put(internal.Itob(int(record.ID)), data) + }) +} + +// GetAPIKey retrieves an existing APIKey object by api key ID. +func (service *Service) GetAPIKey(keyID portainer.APIKeyID) (*portainer.APIKey, error) { + var apiKey *portainer.APIKey + + err := service.connection.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + item := bucket.Get(internal.Itob(int(keyID))) + if item == nil { + return errors.ErrObjectNotFound + } + + err := internal.UnmarshalObject(item, &apiKey) + if err != nil { + return err + } + + return nil + }) + + return apiKey, err +} + +func (service *Service) UpdateAPIKey(key *portainer.APIKey) error { + identifier := internal.Itob(int(key.ID)) + return internal.UpdateObject(service.connection, BucketName, identifier, key) +} + +func (service *Service) DeleteAPIKey(ID portainer.APIKeyID) error { + return service.connection.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + return bucket.Delete(internal.Itob(int(ID))) + }) +} diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go index 448a454e9..a2f1684b7 100644 --- a/api/bolt/datastore.go +++ b/api/bolt/datastore.go @@ -5,6 +5,7 @@ import ( "path" "time" + "github.com/portainer/portainer/api/bolt/apikeyrepository" "github.com/portainer/portainer/api/bolt/helmuserrepository" "github.com/boltdb/bolt" @@ -60,6 +61,7 @@ type Store struct { RegistryService *registry.Service ResourceControlService *resourcecontrol.Service RoleService *role.Service + APIKeyRepositoryService *apikeyrepository.Service ScheduleService *schedule.Service SettingsService *settings.Service SSLSettingsService *ssl.Service diff --git a/api/bolt/services.go b/api/bolt/services.go index 849efa496..1e5e1826d 100644 --- a/api/bolt/services.go +++ b/api/bolt/services.go @@ -2,6 +2,7 @@ package bolt import ( portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/apikeyrepository" "github.com/portainer/portainer/api/bolt/customtemplate" "github.com/portainer/portainer/api/bolt/dockerhub" "github.com/portainer/portainer/api/bolt/edgegroup" @@ -155,6 +156,12 @@ func (store *Store) initServices() error { } store.UserService = userService + apiKeyService, err := apikeyrepository.NewService(store.connection) + if err != nil { + return err + } + store.APIKeyRepositoryService = apiKeyService + versionService, err := version.NewService(store.connection) if err != nil { return err @@ -231,6 +238,11 @@ func (store *Store) Role() portainer.RoleService { return store.RoleService } +// APIKeyRepository gives access to the api-key data management layer +func (store *Store) APIKeyRepository() portainer.APIKeyRepository { + return store.APIKeyRepositoryService +} + // Settings gives access to the Settings data management layer func (store *Store) Settings() portainer.SettingsService { return store.SettingsService diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index bfb2a3416..4d685a182 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -10,6 +10,7 @@ import ( "github.com/portainer/libhelm" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/apikey" "github.com/portainer/portainer/api/bolt" "github.com/portainer/portainer/api/chisel" "github.com/portainer/portainer/api/cli" @@ -116,6 +117,10 @@ func initHelmPackageManager(assetsPath string) (libhelm.HelmPackageManager, erro return libhelm.NewHelmPackageManager(libhelm.HelmConfig{BinaryPath: assetsPath}) } +func initAPIKeyService(datastore portainer.DataStore) apikey.APIKeyService { + return apikey.NewAPIKeyService(datastore.APIKeyRepository(), datastore.User()) +} + func initJWTService(dataStore portainer.DataStore) (portainer.JWTService, error) { settings, err := dataStore.Settings().Settings() if err != nil { @@ -457,6 +462,8 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { log.Fatal(err) } + apiKeyService := initAPIKeyService(dataStore) + jwtService, err := initJWTService(dataStore) if err != nil { log.Fatalf("failed initializing JWT service: %v", err) @@ -620,6 +627,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { KubernetesDeployer: kubernetesDeployer, HelmPackageManager: helmPackageManager, CryptoService: cryptoService, + APIKeyService: apiKeyService, JWTService: jwtService, FileService: fileService, LDAPService: ldapService, diff --git a/api/go.mod b/api/go.mod index d28c6e5b2..2934ad9de 100644 --- a/api/go.mod +++ b/api/go.mod @@ -22,6 +22,7 @@ require ( github.com/gorilla/mux v1.7.3 github.com/gorilla/securecookie v1.1.1 github.com/gorilla/websocket v1.4.2 + github.com/hashicorp/golang-lru v0.5.4 github.com/joho/godotenv v1.3.0 github.com/jpillora/chisel v0.0.0-20190724232113-f3a8df20e389 github.com/json-iterator/go v1.1.11 diff --git a/api/go.sum b/api/go.sum index d7a2d2bd7..225cecd0d 100644 --- a/api/go.sum +++ b/api/go.sum @@ -425,6 +425,8 @@ github.com/hashicorp/go-multierror v0.0.0-20161216184304-ed905158d874/go.mod h1: github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= diff --git a/api/http/handler/auth/logout.go b/api/http/handler/auth/logout.go index d2d2303d8..6e4624725 100644 --- a/api/http/handler/auth/logout.go +++ b/api/http/handler/auth/logout.go @@ -11,6 +11,7 @@ import ( // @id Logout // @summary Logout // @description **Access policy**: authenticated +// @security ApiKeyAuth // @security jwt // @tags auth // @success 204 "Success" diff --git a/api/http/handler/backup/backup.go b/api/http/handler/backup/backup.go index 61fba3cc6..c9ac37443 100644 --- a/api/http/handler/backup/backup.go +++ b/api/http/handler/backup/backup.go @@ -26,6 +26,7 @@ func (p *backupPayload) Validate(r *http.Request) error { // @description Creates an archive with a system data snapshot that could be used to restore the system. // @description **Access policy**: admin // @tags backup +// @security ApiKeyAuth // @security jwt // @accept json // @produce octet-stream diff --git a/api/http/handler/customtemplates/customtemplate_create.go b/api/http/handler/customtemplates/customtemplate_create.go index c4e375b43..970ba171c 100644 --- a/api/http/handler/customtemplates/customtemplate_create.go +++ b/api/http/handler/customtemplates/customtemplate_create.go @@ -22,6 +22,7 @@ import ( // @description Create a custom template. // @description **Access policy**: authenticated // @tags custom_templates +// @security ApiKeyAuth // @security jwt // @accept json,multipart/form-data // @produce json diff --git a/api/http/handler/customtemplates/customtemplate_delete.go b/api/http/handler/customtemplates/customtemplate_delete.go index f5a865084..b1398c810 100644 --- a/api/http/handler/customtemplates/customtemplate_delete.go +++ b/api/http/handler/customtemplates/customtemplate_delete.go @@ -18,6 +18,7 @@ import ( // @description Remove a template. // @description **Access policy**: authenticated // @tags custom_templates +// @security ApiKeyAuth // @security jwt // @param id path int true "Template identifier" // @success 204 "Success" diff --git a/api/http/handler/customtemplates/customtemplate_file.go b/api/http/handler/customtemplates/customtemplate_file.go index 2d7d29a88..1554275d3 100644 --- a/api/http/handler/customtemplates/customtemplate_file.go +++ b/api/http/handler/customtemplates/customtemplate_file.go @@ -19,6 +19,7 @@ type fileResponse struct { // @description Retrieve the content of the Stack file for the specified custom template // @description **Access policy**: authenticated // @tags custom_templates +// @security ApiKeyAuth // @security jwt // @produce json // @param id path int true "Template identifier" diff --git a/api/http/handler/customtemplates/customtemplate_inspect.go b/api/http/handler/customtemplates/customtemplate_inspect.go index 118a095be..cde2f537b 100644 --- a/api/http/handler/customtemplates/customtemplate_inspect.go +++ b/api/http/handler/customtemplates/customtemplate_inspect.go @@ -18,6 +18,7 @@ import ( // @description Retrieve details about a template. // @description **Access policy**: authenticated // @tags custom_templates +// @security ApiKeyAuth // @security jwt // @produce json // @param id path int true "Template identifier" diff --git a/api/http/handler/customtemplates/customtemplate_list.go b/api/http/handler/customtemplates/customtemplate_list.go index e6d616de3..63d1ab05f 100644 --- a/api/http/handler/customtemplates/customtemplate_list.go +++ b/api/http/handler/customtemplates/customtemplate_list.go @@ -17,6 +17,7 @@ import ( // @description List available custom templates. // @description **Access policy**: authenticated // @tags custom_templates +// @security ApiKeyAuth // @security jwt // @produce json // @param type query []int true "Template types" Enums(1,2,3) diff --git a/api/http/handler/customtemplates/customtemplate_update.go b/api/http/handler/customtemplates/customtemplate_update.go index 0deac2c17..8abf9a573 100644 --- a/api/http/handler/customtemplates/customtemplate_update.go +++ b/api/http/handler/customtemplates/customtemplate_update.go @@ -62,6 +62,7 @@ func (payload *customTemplateUpdatePayload) Validate(r *http.Request) error { // @description Update a template. // @description **Access policy**: authenticated // @tags custom_templates +// @security ApiKeyAuth // @security jwt // @accept json // @produce json diff --git a/api/http/handler/edgegroups/edgegroup_create.go b/api/http/handler/edgegroups/edgegroup_create.go index a8d63f6e4..b83a7c015 100644 --- a/api/http/handler/edgegroups/edgegroup_create.go +++ b/api/http/handler/edgegroups/edgegroup_create.go @@ -36,6 +36,7 @@ func (payload *edgeGroupCreatePayload) Validate(r *http.Request) error { // @summary Create an EdgeGroup // @description **Access policy**: administrator // @tags edge_groups +// @security ApiKeyAuth // @security jwt // @accept json // @produce json diff --git a/api/http/handler/edgegroups/edgegroup_delete.go b/api/http/handler/edgegroups/edgegroup_delete.go index 468df4b04..14fb1f75a 100644 --- a/api/http/handler/edgegroups/edgegroup_delete.go +++ b/api/http/handler/edgegroups/edgegroup_delete.go @@ -15,6 +15,7 @@ import ( // @summary Deletes an EdgeGroup // @description **Access policy**: administrator // @tags edge_groups +// @security ApiKeyAuth // @security jwt // @param id path int true "EdgeGroup Id" // @success 204 diff --git a/api/http/handler/edgegroups/edgegroup_inspect.go b/api/http/handler/edgegroups/edgegroup_inspect.go index cb81e18b7..698f2ce8f 100644 --- a/api/http/handler/edgegroups/edgegroup_inspect.go +++ b/api/http/handler/edgegroups/edgegroup_inspect.go @@ -14,6 +14,7 @@ import ( // @summary Inspects an EdgeGroup // @description **Access policy**: administrator // @tags edge_groups +// @security ApiKeyAuth // @security jwt // @produce json // @param id path int true "EdgeGroup Id" diff --git a/api/http/handler/edgegroups/edgegroup_list.go b/api/http/handler/edgegroups/edgegroup_list.go index f809e8c92..2d9cc204b 100644 --- a/api/http/handler/edgegroups/edgegroup_list.go +++ b/api/http/handler/edgegroups/edgegroup_list.go @@ -19,6 +19,7 @@ type decoratedEdgeGroup struct { // @summary list EdgeGroups // @description **Access policy**: administrator // @tags edge_groups +// @security ApiKeyAuth // @security jwt // @produce json // @success 200 {array} decoratedEdgeGroup "EdgeGroups" diff --git a/api/http/handler/edgegroups/edgegroup_update.go b/api/http/handler/edgegroups/edgegroup_update.go index 12b1c9ca7..13d42b399 100644 --- a/api/http/handler/edgegroups/edgegroup_update.go +++ b/api/http/handler/edgegroups/edgegroup_update.go @@ -38,6 +38,7 @@ func (payload *edgeGroupUpdatePayload) Validate(r *http.Request) error { // @summary Updates an EdgeGroup // @description **Access policy**: administrator // @tags edge_groups +// @security ApiKeyAuth // @security jwt // @accept json // @produce json diff --git a/api/http/handler/edgejobs/edgejob_create.go b/api/http/handler/edgejobs/edgejob_create.go index d5facd3c0..f9819e821 100644 --- a/api/http/handler/edgejobs/edgejob_create.go +++ b/api/http/handler/edgejobs/edgejob_create.go @@ -18,6 +18,7 @@ import ( // @summary Create an EdgeJob // @description **Access policy**: administrator // @tags edge_jobs +// @security ApiKeyAuth // @security jwt // @produce json // @param method query string true "Creation Method" Enums(file, string) diff --git a/api/http/handler/edgejobs/edgejob_delete.go b/api/http/handler/edgejobs/edgejob_delete.go index a2686b131..a3585f071 100644 --- a/api/http/handler/edgejobs/edgejob_delete.go +++ b/api/http/handler/edgejobs/edgejob_delete.go @@ -15,6 +15,7 @@ import ( // @summary Delete an EdgeJob // @description **Access policy**: administrator // @tags edge_jobs +// @security ApiKeyAuth // @security jwt // @param id path string true "EdgeJob Id" // @success 204 diff --git a/api/http/handler/edgejobs/edgejob_file.go b/api/http/handler/edgejobs/edgejob_file.go index e38b9a448..8e6363504 100644 --- a/api/http/handler/edgejobs/edgejob_file.go +++ b/api/http/handler/edgejobs/edgejob_file.go @@ -18,6 +18,7 @@ type edgeJobFileResponse struct { // @summary Fetch a file of an EdgeJob // @description **Access policy**: administrator // @tags edge_jobs +// @security ApiKeyAuth // @security jwt // @produce json // @param id path string true "EdgeJob Id" diff --git a/api/http/handler/edgejobs/edgejob_inspect.go b/api/http/handler/edgejobs/edgejob_inspect.go index 1f402f0b9..64e5a35dd 100644 --- a/api/http/handler/edgejobs/edgejob_inspect.go +++ b/api/http/handler/edgejobs/edgejob_inspect.go @@ -19,6 +19,7 @@ type edgeJobInspectResponse struct { // @summary Inspect an EdgeJob // @description **Access policy**: administrator // @tags edge_jobs +// @security ApiKeyAuth // @security jwt // @produce json // @param id path string true "EdgeJob Id" diff --git a/api/http/handler/edgejobs/edgejob_list.go b/api/http/handler/edgejobs/edgejob_list.go index 7eca94c0a..00f1be8a9 100644 --- a/api/http/handler/edgejobs/edgejob_list.go +++ b/api/http/handler/edgejobs/edgejob_list.go @@ -11,6 +11,7 @@ import ( // @summary Fetch EdgeJobs list // @description **Access policy**: administrator // @tags edge_jobs +// @security ApiKeyAuth // @security jwt // @produce json // @success 200 {array} portainer.EdgeJob diff --git a/api/http/handler/edgejobs/edgejob_tasklogs_clear.go b/api/http/handler/edgejobs/edgejob_tasklogs_clear.go index 1f34f7854..927b5b598 100644 --- a/api/http/handler/edgejobs/edgejob_tasklogs_clear.go +++ b/api/http/handler/edgejobs/edgejob_tasklogs_clear.go @@ -15,6 +15,7 @@ import ( // @summary Clear the log for a specifc task on an EdgeJob // @description **Access policy**: administrator // @tags edge_jobs +// @security ApiKeyAuth // @security jwt // @produce json // @param id path string true "EdgeJob Id" diff --git a/api/http/handler/edgejobs/edgejob_tasklogs_collect.go b/api/http/handler/edgejobs/edgejob_tasklogs_collect.go index be97a2320..7b429085b 100644 --- a/api/http/handler/edgejobs/edgejob_tasklogs_collect.go +++ b/api/http/handler/edgejobs/edgejob_tasklogs_collect.go @@ -14,6 +14,7 @@ import ( // @summary Collect the log for a specifc task on an EdgeJob // @description **Access policy**: administrator // @tags edge_jobs +// @security ApiKeyAuth // @security jwt // @produce json // @param id path string true "EdgeJob Id" diff --git a/api/http/handler/edgejobs/edgejob_tasklogs_inspect.go b/api/http/handler/edgejobs/edgejob_tasklogs_inspect.go index 320f1bfe9..c801c3d08 100644 --- a/api/http/handler/edgejobs/edgejob_tasklogs_inspect.go +++ b/api/http/handler/edgejobs/edgejob_tasklogs_inspect.go @@ -17,6 +17,7 @@ type fileResponse struct { // @summary Fetch the log for a specifc task on an EdgeJob // @description **Access policy**: administrator // @tags edge_jobs +// @security ApiKeyAuth // @security jwt // @produce json // @param id path string true "EdgeJob Id" diff --git a/api/http/handler/edgejobs/edgejob_tasks_list.go b/api/http/handler/edgejobs/edgejob_tasks_list.go index 430ccb3b2..22933321e 100644 --- a/api/http/handler/edgejobs/edgejob_tasks_list.go +++ b/api/http/handler/edgejobs/edgejob_tasks_list.go @@ -21,6 +21,7 @@ type taskContainer struct { // @summary Fetch the list of tasks on an EdgeJob // @description **Access policy**: administrator // @tags edge_jobs +// @security ApiKeyAuth // @security jwt // @produce json // @param id path string true "EdgeJob Id" diff --git a/api/http/handler/edgejobs/edgejob_update.go b/api/http/handler/edgejobs/edgejob_update.go index e9753c3f9..f4071c5e0 100644 --- a/api/http/handler/edgejobs/edgejob_update.go +++ b/api/http/handler/edgejobs/edgejob_update.go @@ -32,6 +32,7 @@ func (payload *edgeJobUpdatePayload) Validate(r *http.Request) error { // @summary Update an EdgeJob // @description **Access policy**: administrator // @tags edge_jobs +// @security ApiKeyAuth // @security jwt // @accept json // @produce json diff --git a/api/http/handler/edgestacks/edgestack_create.go b/api/http/handler/edgestacks/edgestack_create.go index 95b65319c..221bff836 100644 --- a/api/http/handler/edgestacks/edgestack_create.go +++ b/api/http/handler/edgestacks/edgestack_create.go @@ -21,6 +21,7 @@ import ( // @summary Create an EdgeStack // @description **Access policy**: administrator // @tags edge_stacks +// @security ApiKeyAuth // @security jwt // @produce json // @param method query string true "Creation Method" Enums(file,string,repository) diff --git a/api/http/handler/edgestacks/edgestack_delete.go b/api/http/handler/edgestacks/edgestack_delete.go index 9b536ba52..e8c711d88 100644 --- a/api/http/handler/edgestacks/edgestack_delete.go +++ b/api/http/handler/edgestacks/edgestack_delete.go @@ -15,6 +15,7 @@ import ( // @summary Delete an EdgeStack // @description **Access policy**: administrator // @tags edge_stacks +// @security ApiKeyAuth // @security jwt // @param id path string true "EdgeStack Id" // @success 204 diff --git a/api/http/handler/edgestacks/edgestack_file.go b/api/http/handler/edgestacks/edgestack_file.go index 4a791d9c0..a5cfb69f1 100644 --- a/api/http/handler/edgestacks/edgestack_file.go +++ b/api/http/handler/edgestacks/edgestack_file.go @@ -18,6 +18,7 @@ type stackFileResponse struct { // @summary Fetches the stack file for an EdgeStack // @description **Access policy**: administrator // @tags edge_stacks +// @security ApiKeyAuth // @security jwt // @produce json // @param id path string true "EdgeStack Id" diff --git a/api/http/handler/edgestacks/edgestack_inspect.go b/api/http/handler/edgestacks/edgestack_inspect.go index 24376e803..9980a602f 100644 --- a/api/http/handler/edgestacks/edgestack_inspect.go +++ b/api/http/handler/edgestacks/edgestack_inspect.go @@ -14,6 +14,7 @@ import ( // @summary Inspect an EdgeStack // @description **Access policy**: administrator // @tags edge_stacks +// @security ApiKeyAuth // @security jwt // @produce json // @param id path string true "EdgeStack Id" diff --git a/api/http/handler/edgestacks/edgestack_list.go b/api/http/handler/edgestacks/edgestack_list.go index 2e4d8a4f3..b7355598d 100644 --- a/api/http/handler/edgestacks/edgestack_list.go +++ b/api/http/handler/edgestacks/edgestack_list.go @@ -11,6 +11,7 @@ import ( // @summary Fetches the list of EdgeStacks // @description **Access policy**: administrator // @tags edge_stacks +// @security ApiKeyAuth // @security jwt // @produce json // @success 200 {array} portainer.EdgeStack diff --git a/api/http/handler/edgestacks/edgestack_update.go b/api/http/handler/edgestacks/edgestack_update.go index f4ac69367..c71697aa8 100644 --- a/api/http/handler/edgestacks/edgestack_update.go +++ b/api/http/handler/edgestacks/edgestack_update.go @@ -35,6 +35,7 @@ func (payload *updateEdgeStackPayload) Validate(r *http.Request) error { // @summary Update an EdgeStack // @description **Access policy**: administrator // @tags edge_stacks +// @security ApiKeyAuth // @security jwt // @accept json // @produce json diff --git a/api/http/handler/edgetemplates/edgetemplate_list.go b/api/http/handler/edgetemplates/edgetemplate_list.go index 05a9c5b03..647f96a41 100644 --- a/api/http/handler/edgetemplates/edgetemplate_list.go +++ b/api/http/handler/edgetemplates/edgetemplate_list.go @@ -19,6 +19,7 @@ type templateFileFormat struct { // @summary Fetches the list of Edge Templates // @description **Access policy**: administrator // @tags edge_templates +// @security ApiKeyAuth // @security jwt // @accept json // @produce json diff --git a/api/http/handler/endpointgroups/endpointgroup_create.go b/api/http/handler/endpointgroups/endpointgroup_create.go index 093163cc9..27446e79d 100644 --- a/api/http/handler/endpointgroups/endpointgroup_create.go +++ b/api/http/handler/endpointgroups/endpointgroup_create.go @@ -36,6 +36,7 @@ func (payload *endpointGroupCreatePayload) Validate(r *http.Request) error { // @description Create a new environment(endpoint) group. // @description **Access policy**: administrator // @tags endpoint_groups +// @security ApiKeyAuth // @security jwt // @accept json // @produce json diff --git a/api/http/handler/endpointgroups/endpointgroup_delete.go b/api/http/handler/endpointgroups/endpointgroup_delete.go index 40c5a1c59..14005203e 100644 --- a/api/http/handler/endpointgroups/endpointgroup_delete.go +++ b/api/http/handler/endpointgroups/endpointgroup_delete.go @@ -16,6 +16,7 @@ import ( // @description Remove an environment(endpoint) group. // @description **Access policy**: administrator // @tags endpoint_groups +// @security ApiKeyAuth // @security jwt // @param id path int true "EndpointGroup identifier" // @success 204 "Success" diff --git a/api/http/handler/endpointgroups/endpointgroup_endpoint_add.go b/api/http/handler/endpointgroups/endpointgroup_endpoint_add.go index 204bf9a19..c04cad969 100644 --- a/api/http/handler/endpointgroups/endpointgroup_endpoint_add.go +++ b/api/http/handler/endpointgroups/endpointgroup_endpoint_add.go @@ -15,6 +15,7 @@ import ( // @description Add an environment(endpoint) to an environment(endpoint) group // @description **Access policy**: administrator // @tags endpoint_groups +// @security ApiKeyAuth // @security jwt // @param id path int true "EndpointGroup identifier" // @param endpointId path int true "Environment(Endpoint) identifier" diff --git a/api/http/handler/endpointgroups/endpointgroup_endpoint_delete.go b/api/http/handler/endpointgroups/endpointgroup_endpoint_delete.go index ef563d0ae..62078fb75 100644 --- a/api/http/handler/endpointgroups/endpointgroup_endpoint_delete.go +++ b/api/http/handler/endpointgroups/endpointgroup_endpoint_delete.go @@ -14,6 +14,7 @@ import ( // @summary Removes environment(endpoint) from an environment(endpoint) group // @description **Access policy**: administrator // @tags endpoint_groups +// @security ApiKeyAuth // @security jwt // @param id path int true "EndpointGroup identifier" // @param endpointId path int true "Environment(Endpoint) identifier" diff --git a/api/http/handler/endpointgroups/endpointgroup_inspect.go b/api/http/handler/endpointgroups/endpointgroup_inspect.go index e689b4ecc..1e5a00d48 100644 --- a/api/http/handler/endpointgroups/endpointgroup_inspect.go +++ b/api/http/handler/endpointgroups/endpointgroup_inspect.go @@ -14,6 +14,7 @@ import ( // @description Retrieve details abont an environment(endpoint) group. // @description **Access policy**: administrator // @tags endpoint_groups +// @security ApiKeyAuth // @security jwt // @accept json // @produce json diff --git a/api/http/handler/endpointgroups/endpointgroup_list.go b/api/http/handler/endpointgroups/endpointgroup_list.go index acfbbe792..8088cb0d1 100644 --- a/api/http/handler/endpointgroups/endpointgroup_list.go +++ b/api/http/handler/endpointgroups/endpointgroup_list.go @@ -15,6 +15,7 @@ import ( // @description only return authorized environment(endpoint) groups. // @description **Access policy**: restricted // @tags endpoint_groups +// @security ApiKeyAuth // @security jwt // @produce json // @success 200 {array} portainer.EndpointGroup "Environment(Endpoint) group" diff --git a/api/http/handler/endpointgroups/endpointgroup_update.go b/api/http/handler/endpointgroups/endpointgroup_update.go index 04cb05edd..ecf369c04 100644 --- a/api/http/handler/endpointgroups/endpointgroup_update.go +++ b/api/http/handler/endpointgroups/endpointgroup_update.go @@ -32,6 +32,7 @@ func (payload *endpointGroupUpdatePayload) Validate(r *http.Request) error { // @description Update an environment(endpoint) group. // @description **Access policy**: administrator // @tags endpoint_groups +// @security ApiKeyAuth // @security jwt // @accept json // @produce json diff --git a/api/http/handler/endpoints/endpoint_association_delete.go b/api/http/handler/endpoints/endpoint_association_delete.go index 0a4a05152..1464be511 100644 --- a/api/http/handler/endpoints/endpoint_association_delete.go +++ b/api/http/handler/endpoints/endpoint_association_delete.go @@ -19,6 +19,7 @@ import ( // @summary De-association an edge environment(endpoint) // @description De-association an edge environment(endpoint). // @description **Access policy**: administrator +// @security ApiKeyAuth // @security jwt // @tags endpoints // @produce json diff --git a/api/http/handler/endpoints/endpoint_create.go b/api/http/handler/endpoints/endpoint_create.go index 54d3ca6f0..0ada96954 100644 --- a/api/http/handler/endpoints/endpoint_create.go +++ b/api/http/handler/endpoints/endpoint_create.go @@ -152,6 +152,7 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error { // @description Create a new environment(endpoint) that will be used to manage an environment(endpoint). // @description **Access policy**: administrator // @tags endpoints +// @security ApiKeyAuth // @security jwt // @accept multipart/form-data // @produce json diff --git a/api/http/handler/endpoints/endpoint_delete.go b/api/http/handler/endpoints/endpoint_delete.go index 719e77534..c4331ef1e 100644 --- a/api/http/handler/endpoints/endpoint_delete.go +++ b/api/http/handler/endpoints/endpoint_delete.go @@ -16,6 +16,7 @@ import ( // @description Remove an environment(endpoint). // @description **Access policy**: administrator // @tags endpoints +// @security ApiKeyAuth // @security jwt // @param id path int true "Environment(Endpoint) identifier" // @success 204 "Success" diff --git a/api/http/handler/endpoints/endpoint_dockerhub_status.go b/api/http/handler/endpoints/endpoint_dockerhub_status.go index 7c5b33277..b990be156 100644 --- a/api/http/handler/endpoints/endpoint_dockerhub_status.go +++ b/api/http/handler/endpoints/endpoint_dockerhub_status.go @@ -29,6 +29,7 @@ type dockerhubStatusResponse struct { // @description get docker pull limits for a docker hub registry in the environment // @description **Access policy**: // @tags endpoints +// @security ApiKeyAuth // @security jwt // @produce json // @param id path int true "endpoint ID" diff --git a/api/http/handler/endpoints/endpoint_inspect.go b/api/http/handler/endpoints/endpoint_inspect.go index 61a6c0b91..621d202c3 100644 --- a/api/http/handler/endpoints/endpoint_inspect.go +++ b/api/http/handler/endpoints/endpoint_inspect.go @@ -15,6 +15,7 @@ import ( // @description Retrieve details about an environment(endpoint). // @description **Access policy**: restricted // @tags endpoints +// @security ApiKeyAuth // @security jwt // @produce json // @param id path int true "Environment(Endpoint) identifier" diff --git a/api/http/handler/endpoints/endpoint_list.go b/api/http/handler/endpoints/endpoint_list.go index d87c278bd..0eec62678 100644 --- a/api/http/handler/endpoints/endpoint_list.go +++ b/api/http/handler/endpoints/endpoint_list.go @@ -20,6 +20,7 @@ import ( // @description only return authorized environments(endpoints). // @description **Access policy**: restricted // @tags endpoints +// @security ApiKeyAuth // @security jwt // @produce json // @param start query int false "Start searching from" diff --git a/api/http/handler/endpoints/endpoint_registries_inspect.go b/api/http/handler/endpoints/endpoint_registries_inspect.go index 8c4e94d84..3bf5f9582 100644 --- a/api/http/handler/endpoints/endpoint_registries_inspect.go +++ b/api/http/handler/endpoints/endpoint_registries_inspect.go @@ -16,6 +16,7 @@ import ( // @summary get registry for environment // @description **Access policy**: authenticated // @tags endpoints +// @security ApiKeyAuth // @security jwt // @produce json // @param id path int true "identifier" diff --git a/api/http/handler/endpoints/endpoint_registries_list.go b/api/http/handler/endpoints/endpoint_registries_list.go index cb22677dc..f1079b039 100644 --- a/api/http/handler/endpoints/endpoint_registries_list.go +++ b/api/http/handler/endpoints/endpoint_registries_list.go @@ -19,6 +19,7 @@ import ( // @description **Access policy**: authenticated // @tags endpoints // @param namespace query string false "required if kubernetes environment, will show registries by namespace" +// @security ApiKeyAuth // @security jwt // @produce json // @param id path int true "Environment(Endpoint) identifier" diff --git a/api/http/handler/endpoints/endpoint_registry_access.go b/api/http/handler/endpoints/endpoint_registry_access.go index 3f02e46c0..9bfbbad9c 100644 --- a/api/http/handler/endpoints/endpoint_registry_access.go +++ b/api/http/handler/endpoints/endpoint_registry_access.go @@ -25,6 +25,7 @@ func (payload *registryAccessPayload) Validate(r *http.Request) error { // @summary update registry access for environment // @description **Access policy**: authenticated // @tags endpoints +// @security ApiKeyAuth // @security jwt // @accept json // @produce json diff --git a/api/http/handler/endpoints/endpoint_settings_update.go b/api/http/handler/endpoints/endpoint_settings_update.go index 6e31fd01b..f02dbb6a1 100644 --- a/api/http/handler/endpoints/endpoint_settings_update.go +++ b/api/http/handler/endpoints/endpoint_settings_update.go @@ -39,6 +39,7 @@ func (payload *endpointSettingsUpdatePayload) Validate(r *http.Request) error { // @summary Update settings for an environment(endpoint) // @description Update settings for an environment(endpoint). // @description **Access policy**: authenticated +// @security ApiKeyAuth // @security jwt // @tags endpoints // @accept json diff --git a/api/http/handler/endpoints/endpoint_snapshot.go b/api/http/handler/endpoints/endpoint_snapshot.go index cbecbad5d..4ce5ad2a8 100644 --- a/api/http/handler/endpoints/endpoint_snapshot.go +++ b/api/http/handler/endpoints/endpoint_snapshot.go @@ -16,6 +16,7 @@ import ( // @description Snapshots an environment(endpoint) // @description **Access policy**: administrator // @tags endpoints +// @security ApiKeyAuth // @security jwt // @param id path int true "Environment(Endpoint) identifier" // @success 204 "Success" diff --git a/api/http/handler/endpoints/endpoint_snapshots.go b/api/http/handler/endpoints/endpoint_snapshots.go index 67aea973e..f66ff92ba 100644 --- a/api/http/handler/endpoints/endpoint_snapshots.go +++ b/api/http/handler/endpoints/endpoint_snapshots.go @@ -15,6 +15,7 @@ import ( // @description Snapshot all environments(endpoints) // @description **Access policy**: administrator // @tags endpoints +// @security ApiKeyAuth // @security jwt // @success 204 "Success" // @failure 500 "Server Error" diff --git a/api/http/handler/endpoints/endpoint_status_inspect.go b/api/http/handler/endpoints/endpoint_status_inspect.go index de32502d9..0a23aff60 100644 --- a/api/http/handler/endpoints/endpoint_status_inspect.go +++ b/api/http/handler/endpoints/endpoint_status_inspect.go @@ -54,6 +54,7 @@ type endpointStatusInspectResponse struct { // @description Environment(Endpoint) for edge agent to check status of environment(endpoint) // @description **Access policy**: restricted only to Edge environments(endpoints) // @tags endpoints +// @security ApiKeyAuth // @security jwt // @param id path int true "Environment(Endpoint) identifier" // @success 200 {object} endpointStatusInspectResponse "Success" diff --git a/api/http/handler/endpoints/endpoint_update.go b/api/http/handler/endpoints/endpoint_update.go index 274eb1056..299e912b9 100644 --- a/api/http/handler/endpoints/endpoint_update.go +++ b/api/http/handler/endpoints/endpoint_update.go @@ -57,6 +57,7 @@ func (payload *endpointUpdatePayload) Validate(r *http.Request) error { // @summary Update an environment(endpoint) // @description Update an environment(endpoint). // @description **Access policy**: authenticated +// @security ApiKeyAuth // @security jwt // @tags endpoints // @accept json diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index 6e22efa59..3992aca28 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -91,6 +91,10 @@ type Handler struct { // @BasePath /api // @schemes http https +// @securitydefinitions.apikey ApiKeyAuth +// @in header +// @name Authorization + // @securitydefinitions.apikey jwt // @in header // @name Authorization diff --git a/api/http/handler/helm/handler.go b/api/http/handler/helm/handler.go index 4ace84c27..f1136f62b 100644 --- a/api/http/handler/helm/handler.go +++ b/api/http/handler/helm/handler.go @@ -26,17 +26,19 @@ type Handler struct { *mux.Router requestBouncer requestBouncer dataStore portainer.DataStore + jwtService portainer.JWTService kubeConfigService kubernetes.KubeConfigService kubernetesDeployer portainer.KubernetesDeployer helmPackageManager libhelm.HelmPackageManager } // NewHandler creates a handler to manage endpoint group operations. -func NewHandler(bouncer requestBouncer, dataStore portainer.DataStore, kubernetesDeployer portainer.KubernetesDeployer, helmPackageManager libhelm.HelmPackageManager, kubeConfigService kubernetes.KubeConfigService) *Handler { +func NewHandler(bouncer requestBouncer, dataStore portainer.DataStore, jwtService portainer.JWTService, kubernetesDeployer portainer.KubernetesDeployer, helmPackageManager libhelm.HelmPackageManager, kubeConfigService kubernetes.KubeConfigService) *Handler { h := &Handler{ Router: mux.NewRouter(), requestBouncer: bouncer, dataStore: dataStore, + jwtService: jwtService, kubernetesDeployer: kubernetesDeployer, helmPackageManager: helmPackageManager, kubeConfigService: kubeConfigService, @@ -91,7 +93,12 @@ func (handler *Handler) getHelmClusterAccess(r *http.Request) (*options.Kubernet return nil, &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment on request context", err} } - bearerToken, err := security.ExtractBearerToken(r) + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} + } + + bearerToken, err := handler.jwtService.GenerateToken(tokenData) if err != nil { return nil, &httperror.HandlerError{http.StatusUnauthorized, "Unauthorized", err} } diff --git a/api/http/handler/helm/helm_delete.go b/api/http/handler/helm/helm_delete.go index 8cfb766f1..f1199219e 100644 --- a/api/http/handler/helm/helm_delete.go +++ b/api/http/handler/helm/helm_delete.go @@ -14,6 +14,7 @@ import ( // @description // @description **Access policy**: authenticated // @tags helm +// @security ApiKeyAuth // @security jwt // @param id path int true "Environment(Endpoint) identifier" // @param release path string true "The name of the release/application to uninstall" diff --git a/api/http/handler/helm/helm_delete_test.go b/api/http/handler/helm/helm_delete_test.go index 1270ec9ac..e5eeb26f6 100644 --- a/api/http/handler/helm/helm_delete_test.go +++ b/api/http/handler/helm/helm_delete_test.go @@ -11,6 +11,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/exec/exectest" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/jwt" "github.com/portainer/portainer/api/kubernetes" "github.com/stretchr/testify/assert" @@ -30,10 +31,13 @@ func Test_helmDelete(t *testing.T) { err = store.User().CreateUser(&portainer.User{Username: "admin", Role: portainer.AdministratorRole}) is.NoError(err, "Error creating a user") + jwtService, err := jwt.NewService("1h", store) + is.NoError(err, "Error initiating jwt service") + kubernetesDeployer := exectest.NewKubernetesDeployer() helmPackageManager := test.NewMockHelmBinaryPackageManager("") kubeConfigService := kubernetes.NewKubeConfigCAService("", "") - h := NewHandler(helper.NewTestRequestBouncer(), store, kubernetesDeployer, helmPackageManager, kubeConfigService) + h := NewHandler(helper.NewTestRequestBouncer(), store, jwtService, kubernetesDeployer, helmPackageManager, kubeConfigService) is.NotNil(h, "Handler should not fail") diff --git a/api/http/handler/helm/helm_install.go b/api/http/handler/helm/helm_install.go index 1bbe1db83..85d84e0fa 100644 --- a/api/http/handler/helm/helm_install.go +++ b/api/http/handler/helm/helm_install.go @@ -38,6 +38,7 @@ var errChartNameInvalid = errors.New("invalid chart name. " + // @description // @description **Access policy**: authenticated // @tags helm +// @security ApiKeyAuth // @security jwt // @accept json // @produce json diff --git a/api/http/handler/helm/helm_install_test.go b/api/http/handler/helm/helm_install_test.go index aef13de8b..f1576acb6 100644 --- a/api/http/handler/helm/helm_install_test.go +++ b/api/http/handler/helm/helm_install_test.go @@ -16,6 +16,7 @@ import ( "github.com/portainer/portainer/api/exec/exectest" "github.com/portainer/portainer/api/http/security" helper "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/api/jwt" "github.com/portainer/portainer/api/kubernetes" "github.com/stretchr/testify/assert" ) @@ -32,10 +33,13 @@ func Test_helmInstall(t *testing.T) { err = store.User().CreateUser(&portainer.User{Username: "admin", Role: portainer.AdministratorRole}) is.NoError(err, "error creating a user") + jwtService, err := jwt.NewService("1h", store) + is.NoError(err, "Error initiating jwt service") + kubernetesDeployer := exectest.NewKubernetesDeployer() helmPackageManager := test.NewMockHelmBinaryPackageManager("") kubeConfigService := kubernetes.NewKubeConfigCAService("", "") - h := NewHandler(helper.NewTestRequestBouncer(), store, kubernetesDeployer, helmPackageManager, kubeConfigService) + h := NewHandler(helper.NewTestRequestBouncer(), store, jwtService, kubernetesDeployer, helmPackageManager, kubeConfigService) is.NotNil(h, "Handler should not fail") diff --git a/api/http/handler/helm/helm_list.go b/api/http/handler/helm/helm_list.go index 7200d0b5b..55e92a184 100644 --- a/api/http/handler/helm/helm_list.go +++ b/api/http/handler/helm/helm_list.go @@ -14,6 +14,7 @@ import ( // @description // @description **Access policy**: authenticated // @tags helm +// @security ApiKeyAuth // @security jwt // @accept json // @produce json diff --git a/api/http/handler/helm/helm_list_test.go b/api/http/handler/helm/helm_list_test.go index 87901b5de..8e78ba794 100644 --- a/api/http/handler/helm/helm_list_test.go +++ b/api/http/handler/helm/helm_list_test.go @@ -12,6 +12,8 @@ import ( "github.com/portainer/libhelm/release" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/exec/exectest" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/jwt" "github.com/portainer/portainer/api/kubernetes" "github.com/stretchr/testify/assert" @@ -20,28 +22,33 @@ import ( ) func Test_helmList(t *testing.T) { + is := assert.New(t) + store, teardown := bolt.MustNewTestStore(true) defer teardown() err := store.Endpoint().CreateEndpoint(&portainer.Endpoint{ID: 1}) - assert.NoError(t, err, "error creating environment") + is.NoError(err, "error creating environment") err = store.User().CreateUser(&portainer.User{Username: "admin", Role: portainer.AdministratorRole}) - assert.NoError(t, err, "error creating a user") + is.NoError(err, "error creating a user") + + jwtService, err := jwt.NewService("1h", store) + is.NoError(err, "Error initialising jwt service") kubernetesDeployer := exectest.NewKubernetesDeployer() helmPackageManager := test.NewMockHelmBinaryPackageManager("") kubeConfigService := kubernetes.NewKubeConfigCAService("", "") - h := NewHandler(helper.NewTestRequestBouncer(), store, kubernetesDeployer, helmPackageManager, kubeConfigService) + h := NewHandler(helper.NewTestRequestBouncer(), store, jwtService, kubernetesDeployer, helmPackageManager, kubeConfigService) // Install a single chart. We expect to get these values back options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default"} h.helmPackageManager.Install(options) t.Run("helmList", func(t *testing.T) { - is := assert.New(t) - req := httptest.NewRequest(http.MethodGet, "/1/kubernetes/helm", nil) + ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1}) + req = req.WithContext(ctx) req.Header.Add("Authorization", "Bearer dummytoken") rr := httptest.NewRecorder() diff --git a/api/http/handler/helm/helm_repo_search.go b/api/http/handler/helm/helm_repo_search.go index 673555e68..3b731a412 100644 --- a/api/http/handler/helm/helm_repo_search.go +++ b/api/http/handler/helm/helm_repo_search.go @@ -16,6 +16,7 @@ import ( // @description **Access policy**: authenticated // @tags helm // @param repo query string true "Helm repository URL" +// @security ApiKeyAuth // @security jwt // @produce json // @success 200 {object} string "Success" diff --git a/api/http/handler/helm/helm_show.go b/api/http/handler/helm/helm_show.go index 76a143f44..797bffa92 100644 --- a/api/http/handler/helm/helm_show.go +++ b/api/http/handler/helm/helm_show.go @@ -20,6 +20,7 @@ import ( // @param repo query string true "Helm repository URL" // @param chart query string true "Chart name" // @param command path string true "chart/values/readme" +// @security ApiKeyAuth // @security jwt // @accept json // @produce text/plain diff --git a/api/http/handler/helm/user_helm_repos.go b/api/http/handler/helm/user_helm_repos.go index d21584468..d97e66fd0 100644 --- a/api/http/handler/helm/user_helm_repos.go +++ b/api/http/handler/helm/user_helm_repos.go @@ -33,6 +33,7 @@ func (p *addHelmRepoUrlPayload) Validate(_ *http.Request) error { // @description Create a user helm repository. // @description **Access policy**: authenticated // @tags helm +// @security ApiKeyAuth // @security jwt // @accept json // @produce json @@ -93,6 +94,7 @@ func (handler *Handler) userCreateHelmRepo(w http.ResponseWriter, r *http.Reques // @description Inspect a user helm repositories. // @description **Access policy**: authenticated // @tags helm +// @security ApiKeyAuth // @security jwt // @produce json // @param id path int true "User identifier" diff --git a/api/http/handler/kubernetes/kubernetes_config.go b/api/http/handler/kubernetes/kubernetes_config.go index 0c9f86b52..bc8ec613b 100644 --- a/api/http/handler/kubernetes/kubernetes_config.go +++ b/api/http/handler/kubernetes/kubernetes_config.go @@ -21,6 +21,7 @@ import ( // @description Generates kubeconfig file enabling client communication with k8s api server // @description **Access policy**: authenticated // @tags kubernetes +// @security ApiKeyAuth // @security jwt // @accept json // @produce json diff --git a/api/http/handler/kubernetes/kubernetes_nodes_limits.go b/api/http/handler/kubernetes/kubernetes_nodes_limits.go index 22c40bd08..f9cd68d38 100644 --- a/api/http/handler/kubernetes/kubernetes_nodes_limits.go +++ b/api/http/handler/kubernetes/kubernetes_nodes_limits.go @@ -15,6 +15,7 @@ import ( // @description Get CPU and memory limits of all nodes within k8s cluster // @description **Access policy**: authenticated // @tags kubernetes +// @security ApiKeyAuth // @security jwt // @accept json // @produce json diff --git a/api/http/handler/kubernetes/namespaces_toggle_system.go b/api/http/handler/kubernetes/namespaces_toggle_system.go index 52948a08f..d60a13e4e 100644 --- a/api/http/handler/kubernetes/namespaces_toggle_system.go +++ b/api/http/handler/kubernetes/namespaces_toggle_system.go @@ -22,6 +22,7 @@ func (payload *namespacesToggleSystemPayload) Validate(r *http.Request) error { // @summary Toggle the system state for a namespace // @description Toggle the system state for a namespace // @description **Access policy**: administrator or environment(endpoint) admin +// @security ApiKeyAuth // @security jwt // @tags kubernetes // @accept json diff --git a/api/http/handler/ldap/ldap_check.go b/api/http/handler/ldap/ldap_check.go index 36c25c407..4013d380c 100644 --- a/api/http/handler/ldap/ldap_check.go +++ b/api/http/handler/ldap/ldap_check.go @@ -22,6 +22,7 @@ func (payload *checkPayload) Validate(r *http.Request) error { // @description Test LDAP connectivity using LDAP details // @description **Access policy**: administrator // @tags ldap +// @security ApiKeyAuth // @security jwt // @accept json // @param body body checkPayload true "details" diff --git a/api/http/handler/motd/motd.go b/api/http/handler/motd/motd.go index 18848585e..84e5d030b 100644 --- a/api/http/handler/motd/motd.go +++ b/api/http/handler/motd/motd.go @@ -30,6 +30,7 @@ type motdData struct { // @summary fetches the message of the day // @description **Access policy**: restricted // @tags motd +// @security ApiKeyAuth // @security jwt // @produce json // @success 200 {object} motdResponse diff --git a/api/http/handler/registries/registry_configure.go b/api/http/handler/registries/registry_configure.go index 0be1a5068..d45bbca38 100644 --- a/api/http/handler/registries/registry_configure.go +++ b/api/http/handler/registries/registry_configure.go @@ -83,6 +83,7 @@ func (payload *registryConfigurePayload) Validate(r *http.Request) error { // @description Configures a registry. // @description **Access policy**: restricted // @tags registries +// @security ApiKeyAuth // @security jwt // @accept json // @produce json diff --git a/api/http/handler/registries/registry_create.go b/api/http/handler/registries/registry_create.go index 0ce637416..60d50edd1 100644 --- a/api/http/handler/registries/registry_create.go +++ b/api/http/handler/registries/registry_create.go @@ -64,6 +64,7 @@ func (payload *registryCreatePayload) Validate(_ *http.Request) error { // @description Create a new registry. // @description **Access policy**: restricted // @tags registries +// @security ApiKeyAuth // @security jwt // @accept json // @produce json diff --git a/api/http/handler/registries/registry_delete.go b/api/http/handler/registries/registry_delete.go index 2cb101267..fa10624e0 100644 --- a/api/http/handler/registries/registry_delete.go +++ b/api/http/handler/registries/registry_delete.go @@ -17,6 +17,7 @@ import ( // @description Remove a registry // @description **Access policy**: restricted // @tags registries +// @security ApiKeyAuth // @security jwt // @param id path int true "Registry identifier" // @success 204 "Success" diff --git a/api/http/handler/registries/registry_inspect.go b/api/http/handler/registries/registry_inspect.go index 0575d04c3..df5605bb6 100644 --- a/api/http/handler/registries/registry_inspect.go +++ b/api/http/handler/registries/registry_inspect.go @@ -16,6 +16,7 @@ import ( // @description Retrieve details about a registry. // @description **Access policy**: restricted // @tags registries +// @security ApiKeyAuth // @security jwt // @produce json // @param id path int true "Registry identifier" diff --git a/api/http/handler/registries/registry_list.go b/api/http/handler/registries/registry_list.go index 8e9519f68..33138121a 100644 --- a/api/http/handler/registries/registry_list.go +++ b/api/http/handler/registries/registry_list.go @@ -16,6 +16,7 @@ import ( // @description will only return authorized registries. // @description **Access policy**: restricted // @tags registries +// @security ApiKeyAuth // @security jwt // @produce json // @success 200 {array} portainer.Registry "Success" diff --git a/api/http/handler/registries/registry_update.go b/api/http/handler/registries/registry_update.go index 449e47236..31c2b164b 100644 --- a/api/http/handler/registries/registry_update.go +++ b/api/http/handler/registries/registry_update.go @@ -39,6 +39,7 @@ func (payload *registryUpdatePayload) Validate(r *http.Request) error { // @description Update a registry // @description **Access policy**: restricted // @tags registries +// @security ApiKeyAuth // @security jwt // @accept json // @produce json diff --git a/api/http/handler/resourcecontrols/resourcecontrol_create.go b/api/http/handler/resourcecontrols/resourcecontrol_create.go index e57476ed5..fc18fb22c 100644 --- a/api/http/handler/resourcecontrols/resourcecontrol_create.go +++ b/api/http/handler/resourcecontrols/resourcecontrol_create.go @@ -58,6 +58,7 @@ func (payload *resourceControlCreatePayload) Validate(r *http.Request) error { // @description Create a new resource control to restrict access to a Docker resource. // @description **Access policy**: administrator // @tags resource_controls +// @security ApiKeyAuth // @security jwt // @accept json // @produce json diff --git a/api/http/handler/resourcecontrols/resourcecontrol_delete.go b/api/http/handler/resourcecontrols/resourcecontrol_delete.go index 37e767803..c051fe6af 100644 --- a/api/http/handler/resourcecontrols/resourcecontrol_delete.go +++ b/api/http/handler/resourcecontrols/resourcecontrol_delete.go @@ -15,6 +15,7 @@ import ( // @description Remove a resource control. // @description **Access policy**: administrator // @tags resource_controls +// @security ApiKeyAuth // @security jwt // @param id path int true "Resource control identifier" // @success 204 "Success" diff --git a/api/http/handler/resourcecontrols/resourcecontrol_update.go b/api/http/handler/resourcecontrols/resourcecontrol_update.go index 6f8bfd8c8..5954c0e4a 100644 --- a/api/http/handler/resourcecontrols/resourcecontrol_update.go +++ b/api/http/handler/resourcecontrols/resourcecontrol_update.go @@ -40,6 +40,7 @@ func (payload *resourceControlUpdatePayload) Validate(r *http.Request) error { // @description Update a resource control // @description **Access policy**: authenticated // @tags resource_controls +// @security ApiKeyAuth // @security jwt // @accept json // @produce json diff --git a/api/http/handler/roles/role_list.go b/api/http/handler/roles/role_list.go index d0007b283..5a6a210e0 100644 --- a/api/http/handler/roles/role_list.go +++ b/api/http/handler/roles/role_list.go @@ -12,6 +12,7 @@ import ( // @description List all roles available for use // @description **Access policy**: administrator // @tags roles +// @security ApiKeyAuth // @security jwt // @produce json // @success 200 {array} portainer.Role "Success" diff --git a/api/http/handler/settings/settings_inspect.go b/api/http/handler/settings/settings_inspect.go index bde6e34d5..5f67ab36d 100644 --- a/api/http/handler/settings/settings_inspect.go +++ b/api/http/handler/settings/settings_inspect.go @@ -12,6 +12,7 @@ import ( // @description Retrieve Portainer settings. // @description **Access policy**: administrator // @tags settings +// @security ApiKeyAuth // @security jwt // @produce json // @success 200 {object} portainer.Settings "Success" diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go index 728e38fa6..2171c47ef 100644 --- a/api/http/handler/settings/settings_update.go +++ b/api/http/handler/settings/settings_update.go @@ -78,6 +78,7 @@ func (payload *settingsUpdatePayload) Validate(r *http.Request) error { // @description Update Portainer settings. // @description **Access policy**: administrator // @tags settings +// @security ApiKeyAuth // @security jwt // @accept json // @produce json diff --git a/api/http/handler/ssl/ssl_inspect.go b/api/http/handler/ssl/ssl_inspect.go index b41faa6c5..98e6ba618 100644 --- a/api/http/handler/ssl/ssl_inspect.go +++ b/api/http/handler/ssl/ssl_inspect.go @@ -12,6 +12,7 @@ import ( // @description Retrieve the ssl settings. // @description **Access policy**: administrator // @tags ssl +// @security ApiKeyAuth // @security jwt // @produce json // @success 200 {object} portainer.SSLSettings "Success" diff --git a/api/http/handler/ssl/ssl_update.go b/api/http/handler/ssl/ssl_update.go index 34dd74171..028cbd73b 100644 --- a/api/http/handler/ssl/ssl_update.go +++ b/api/http/handler/ssl/ssl_update.go @@ -28,6 +28,7 @@ func (payload *sslUpdatePayload) Validate(r *http.Request) error { // @description Update the ssl settings. // @description **Access policy**: administrator // @tags ssl +// @security ApiKeyAuth // @security jwt // @accept json // @produce json diff --git a/api/http/handler/stacks/stack_associate.go b/api/http/handler/stacks/stack_associate.go index 84c3aca66..8968bbe33 100644 --- a/api/http/handler/stacks/stack_associate.go +++ b/api/http/handler/stacks/stack_associate.go @@ -18,6 +18,7 @@ import ( // @summary Associate an orphaned stack to a new environment(endpoint) // @description **Access policy**: administrator // @tags stacks +// @security ApiKeyAuth // @security jwt // @produce json // @param id path int true "Stack identifier" diff --git a/api/http/handler/stacks/stack_create.go b/api/http/handler/stacks/stack_create.go index 6381ccc5a..2bd19bfc7 100644 --- a/api/http/handler/stacks/stack_create.go +++ b/api/http/handler/stacks/stack_create.go @@ -35,6 +35,7 @@ func (handler *Handler) cleanUp(stack *portainer.Stack, doCleanUp *bool) error { // @description Deploy a new stack into a Docker environment(endpoint) specified via the environment(endpoint) identifier. // @description **Access policy**: authenticated // @tags stacks +// @security ApiKeyAuth // @security jwt // @accept json,multipart/form-data // @produce json diff --git a/api/http/handler/stacks/stack_delete.go b/api/http/handler/stacks/stack_delete.go index a87bdf847..196eaede7 100644 --- a/api/http/handler/stacks/stack_delete.go +++ b/api/http/handler/stacks/stack_delete.go @@ -25,6 +25,7 @@ import ( // @description Remove a stack. // @description **Access policy**: restricted // @tags stacks +// @security ApiKeyAuth // @security jwt // @param id path int true "Stack identifier" // @param external query boolean false "Set to true to delete an external stack. Only external Swarm stacks are supported" diff --git a/api/http/handler/stacks/stack_file.go b/api/http/handler/stacks/stack_file.go index d0f313505..5d849a651 100644 --- a/api/http/handler/stacks/stack_file.go +++ b/api/http/handler/stacks/stack_file.go @@ -23,6 +23,7 @@ type stackFileResponse struct { // @description Get Stack file content. // @description **Access policy**: restricted // @tags stacks +// @security ApiKeyAuth // @security jwt // @produce json // @param id path int true "Stack identifier" diff --git a/api/http/handler/stacks/stack_inspect.go b/api/http/handler/stacks/stack_inspect.go index b1cb8638b..8edaa1a5b 100644 --- a/api/http/handler/stacks/stack_inspect.go +++ b/api/http/handler/stacks/stack_inspect.go @@ -19,6 +19,7 @@ import ( // @description Retrieve details about a stack. // @description **Access policy**: restricted // @tags stacks +// @security ApiKeyAuth // @security jwt // @produce json // @param id path int true "Stack identifier" diff --git a/api/http/handler/stacks/stack_list.go b/api/http/handler/stacks/stack_list.go index 42542c6dd..9b03776f9 100644 --- a/api/http/handler/stacks/stack_list.go +++ b/api/http/handler/stacks/stack_list.go @@ -26,6 +26,7 @@ type stackListOperationFilters struct { // @description will only return the list of stacks the user have access to. // @description **Access policy**: authenticated // @tags stacks +// @security ApiKeyAuth // @security jwt // @param filters query string false "Filters to process on the stack list. Encoded as JSON (a map[string]string). For example, {'SwarmID': 'jpofkc0i9uo9wtx1zesuk649w'} will only return stacks that are part of the specified Swarm cluster. Available filters: EndpointID, SwarmID." // @success 200 {array} portainer.Stack "Success" diff --git a/api/http/handler/stacks/stack_migrate.go b/api/http/handler/stacks/stack_migrate.go index e26f2dd93..03222ba33 100644 --- a/api/http/handler/stacks/stack_migrate.go +++ b/api/http/handler/stacks/stack_migrate.go @@ -36,6 +36,7 @@ func (payload *stackMigratePayload) Validate(r *http.Request) error { // @description Migrate a stack from an environment(endpoint) to another environment(endpoint). It will re-create the stack inside the target environment(endpoint) before removing the original stack. // @description **Access policy**: authenticated // @tags stacks +// @security ApiKeyAuth // @security jwt // @produce json // @param id path int true "Stack identifier" diff --git a/api/http/handler/stacks/stack_start.go b/api/http/handler/stacks/stack_start.go index 6144d4230..ad5a07eff 100644 --- a/api/http/handler/stacks/stack_start.go +++ b/api/http/handler/stacks/stack_start.go @@ -22,6 +22,7 @@ import ( // @description Starts a stopped Stack. // @description **Access policy**: authenticated // @tags stacks +// @security ApiKeyAuth // @security jwt // @param id path int true "Stack identifier" // @success 200 {object} portainer.Stack "Success" diff --git a/api/http/handler/stacks/stack_stop.go b/api/http/handler/stacks/stack_stop.go index 5de94af75..416b6ba90 100644 --- a/api/http/handler/stacks/stack_stop.go +++ b/api/http/handler/stacks/stack_stop.go @@ -20,6 +20,7 @@ import ( // @description Stops a stopped Stack. // @description **Access policy**: authenticated // @tags stacks +// @security ApiKeyAuth // @security jwt // @param id path int true "Stack identifier" // @success 200 {object} portainer.Stack "Success" diff --git a/api/http/handler/stacks/stack_update.go b/api/http/handler/stacks/stack_update.go index 6cca116b4..a358e0318 100644 --- a/api/http/handler/stacks/stack_update.go +++ b/api/http/handler/stacks/stack_update.go @@ -53,6 +53,7 @@ func (payload *updateSwarmStackPayload) Validate(r *http.Request) error { // @description Update a stack. // @description **Access policy**: authenticated // @tags stacks +// @security ApiKeyAuth // @security jwt // @accept json // @produce json diff --git a/api/http/handler/stacks/stack_update_git.go b/api/http/handler/stacks/stack_update_git.go index 75713e14d..fe01c51e9 100644 --- a/api/http/handler/stacks/stack_update_git.go +++ b/api/http/handler/stacks/stack_update_git.go @@ -42,6 +42,7 @@ func (payload *stackGitUpdatePayload) Validate(r *http.Request) error { // @description Update the Git settings in a stack, e.g., RepositoryReferenceName and AutoUpdate // @description **Access policy**: authenticated // @tags stacks +// @security ApiKeyAuth // @security jwt // @accept json // @produce json diff --git a/api/http/handler/stacks/stack_update_git_redeploy.go b/api/http/handler/stacks/stack_update_git_redeploy.go index 0a2d58f1c..5512135e2 100644 --- a/api/http/handler/stacks/stack_update_git_redeploy.go +++ b/api/http/handler/stacks/stack_update_git_redeploy.go @@ -40,6 +40,7 @@ func (payload *stackGitRedployPayload) Validate(r *http.Request) error { // @description Pull and redeploy a stack via Git // @description **Access policy**: authenticated // @tags stacks +// @security ApiKeyAuth // @security jwt // @accept json // @produce json diff --git a/api/http/handler/status/status_inspect_version.go b/api/http/handler/status/status_inspect_version.go index 58ef46a51..99c5a4282 100644 --- a/api/http/handler/status/status_inspect_version.go +++ b/api/http/handler/status/status_inspect_version.go @@ -27,6 +27,7 @@ type githubData struct { // @summary Check for portainer updates // @description Check if portainer has an update available // @description **Access policy**: authenticated +// @security ApiKeyAuth // @security jwt // @tags status // @produce json diff --git a/api/http/handler/tags/tag_create.go b/api/http/handler/tags/tag_create.go index 0a47a0e64..efea698d2 100644 --- a/api/http/handler/tags/tag_create.go +++ b/api/http/handler/tags/tag_create.go @@ -28,6 +28,7 @@ func (payload *tagCreatePayload) Validate(r *http.Request) error { // @description Create a new tag. // @description **Access policy**: administrator // @tags tags +// @security ApiKeyAuth // @security jwt // @accept json // @produce json diff --git a/api/http/handler/tags/tag_delete.go b/api/http/handler/tags/tag_delete.go index 9946e2cc3..47fd6b6a6 100644 --- a/api/http/handler/tags/tag_delete.go +++ b/api/http/handler/tags/tag_delete.go @@ -16,6 +16,7 @@ import ( // @description Remove a tag. // @description **Access policy**: administrator // @tags tags +// @security ApiKeyAuth // @security jwt // @param id path int true "Tag identifier" // @success 204 "Success" diff --git a/api/http/handler/tags/tag_list.go b/api/http/handler/tags/tag_list.go index 070f5eba5..9b7d427f4 100644 --- a/api/http/handler/tags/tag_list.go +++ b/api/http/handler/tags/tag_list.go @@ -12,6 +12,7 @@ import ( // @description List tags. // @description **Access policy**: authenticated // @tags tags +// @security ApiKeyAuth // @security jwt // @produce json // @success 200 {array} portainer.Tag "Success" diff --git a/api/http/handler/teammemberships/teammembership_create.go b/api/http/handler/teammemberships/teammembership_create.go index 3096d2105..f7ab742be 100644 --- a/api/http/handler/teammemberships/teammembership_create.go +++ b/api/http/handler/teammemberships/teammembership_create.go @@ -39,6 +39,7 @@ func (payload *teamMembershipCreatePayload) Validate(r *http.Request) error { // @description Create a new team memberships. Access is only available to administrators leaders of the associated team. // @description **Access policy**: administrator // @tags team_memberships +// @security ApiKeyAuth // @security jwt // @accept json // @produce json diff --git a/api/http/handler/teammemberships/teammembership_delete.go b/api/http/handler/teammemberships/teammembership_delete.go index f53997ca7..ee1d8046a 100644 --- a/api/http/handler/teammemberships/teammembership_delete.go +++ b/api/http/handler/teammemberships/teammembership_delete.go @@ -17,6 +17,7 @@ import ( // @description Remove a team membership. Access is only available to administrators leaders of the associated team. // @description **Access policy**: administrator // @tags team_memberships +// @security ApiKeyAuth // @security jwt // @param id path int true "TeamMembership identifier" // @success 204 "Success" diff --git a/api/http/handler/teammemberships/teammembership_list.go b/api/http/handler/teammemberships/teammembership_list.go index e88b67a48..a7b8a0d60 100644 --- a/api/http/handler/teammemberships/teammembership_list.go +++ b/api/http/handler/teammemberships/teammembership_list.go @@ -14,6 +14,7 @@ import ( // @description List team memberships. Access is only available to administrators and team leaders. // @description **Access policy**: administrator // @tags team_memberships +// @security ApiKeyAuth // @security jwt // @produce json // @success 200 {array} portainer.TeamMembership "Success" diff --git a/api/http/handler/teammemberships/teammembership_update.go b/api/http/handler/teammemberships/teammembership_update.go index e1c28b66e..ea029e0e6 100644 --- a/api/http/handler/teammemberships/teammembership_update.go +++ b/api/http/handler/teammemberships/teammembership_update.go @@ -40,6 +40,7 @@ func (payload *teamMembershipUpdatePayload) Validate(r *http.Request) error { // @description Update a team membership. Access is only available to administrators leaders of the associated team. // @description **Access policy**: administrator // @tags team_memberships +// @security ApiKeyAuth // @security jwt // @accept json // @produce json diff --git a/api/http/handler/teams/team_create.go b/api/http/handler/teams/team_create.go index 392b54ce3..6277ed262 100644 --- a/api/http/handler/teams/team_create.go +++ b/api/http/handler/teams/team_create.go @@ -29,6 +29,7 @@ func (payload *teamCreatePayload) Validate(r *http.Request) error { // @description Create a new team. // @description **Access policy**: administrator // @tags teams +// @security ApiKeyAuth // @security jwt // @accept json // @produce json diff --git a/api/http/handler/teams/team_delete.go b/api/http/handler/teams/team_delete.go index 36b3c4aee..65636ca03 100644 --- a/api/http/handler/teams/team_delete.go +++ b/api/http/handler/teams/team_delete.go @@ -16,6 +16,7 @@ import ( // @description Remove a team. // @description **Access policy**: administrator // @tags teams +// @security ApiKeyAuth // @security jwt // @param id path string true "Team Id" // @success 204 "Success" diff --git a/api/http/handler/teams/team_inspect.go b/api/http/handler/teams/team_inspect.go index ba8229fd4..05e9ae8fc 100644 --- a/api/http/handler/teams/team_inspect.go +++ b/api/http/handler/teams/team_inspect.go @@ -17,6 +17,7 @@ import ( // @description Retrieve details about a team. Access is only available for administrator and leaders of that team. // @description **Access policy**: administrator // @tags teams +// @security ApiKeyAuth // @security jwt // @produce json // @param id path int true "Team identifier" diff --git a/api/http/handler/teams/team_list.go b/api/http/handler/teams/team_list.go index f6b095d39..72fb36c81 100644 --- a/api/http/handler/teams/team_list.go +++ b/api/http/handler/teams/team_list.go @@ -13,6 +13,7 @@ import ( // @description List teams. For non-administrator users, will only list the teams they are member of. // @description **Access policy**: restricted // @tags teams +// @security ApiKeyAuth // @security jwt // @produce json // @success 200 {array} portainer.Team "Success" diff --git a/api/http/handler/teams/team_memberships.go b/api/http/handler/teams/team_memberships.go index 422ef2ac5..7be4090e5 100644 --- a/api/http/handler/teams/team_memberships.go +++ b/api/http/handler/teams/team_memberships.go @@ -16,6 +16,7 @@ import ( // @description List team memberships. Access is only available to administrators and team leaders. // @description **Access policy**: restricted // @tags team_memberships +// @security ApiKeyAuth // @security jwt // @produce json // @param id path string true "Team Id" diff --git a/api/http/handler/teams/team_update.go b/api/http/handler/teams/team_update.go index e73a00c74..e13ff8142 100644 --- a/api/http/handler/teams/team_update.go +++ b/api/http/handler/teams/team_update.go @@ -24,6 +24,7 @@ func (payload *teamUpdatePayload) Validate(r *http.Request) error { // @description Update a team. // @description **Access policy**: administrator // @tags teams +// @security ApiKeyAuth // @security jwt // @accept json // @produce json diff --git a/api/http/handler/templates/template_file.go b/api/http/handler/templates/template_file.go index e5b5c3d4a..584f18545 100644 --- a/api/http/handler/templates/template_file.go +++ b/api/http/handler/templates/template_file.go @@ -40,6 +40,7 @@ func (payload *filePayload) Validate(r *http.Request) error { // @description Get a template's file // @description **Access policy**: authenticated // @tags templates +// @security ApiKeyAuth // @security jwt // @accept json // @produce json diff --git a/api/http/handler/templates/template_list.go b/api/http/handler/templates/template_list.go index 57d8da452..e784443de 100644 --- a/api/http/handler/templates/template_list.go +++ b/api/http/handler/templates/template_list.go @@ -19,6 +19,7 @@ type listResponse struct { // @description List available templates. // @description **Access policy**: authenticated // @tags templates +// @security ApiKeyAuth // @security jwt // @produce json // @success 200 {object} listResponse "Success" diff --git a/api/http/handler/upload/upload_tls.go b/api/http/handler/upload/upload_tls.go index f95942eb5..db3ba7c5f 100644 --- a/api/http/handler/upload/upload_tls.go +++ b/api/http/handler/upload/upload_tls.go @@ -15,6 +15,7 @@ import ( // @description Use this environment(endpoint) to upload TLS files. // @description **Access policy**: administrator // @tags upload +// @security ApiKeyAuth // @security jwt // @accept multipart/form-data // @produce json diff --git a/api/http/handler/users/handler.go b/api/http/handler/users/handler.go index f6c399c7e..261ed3577 100644 --- a/api/http/handler/users/handler.go +++ b/api/http/handler/users/handler.go @@ -5,6 +5,7 @@ import ( httperror "github.com/portainer/libhttp/error" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/apikey" "github.com/portainer/portainer/api/http/security" "net/http" @@ -27,14 +28,18 @@ func hideFields(user *portainer.User) { // Handler is the HTTP handler used to handle user operations. type Handler struct { *mux.Router + bouncer *security.RequestBouncer + apiKeyService apikey.APIKeyService DataStore portainer.DataStore CryptoService portainer.CryptoService } // NewHandler creates a handler to manage user operations. -func NewHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimiter) *Handler { +func NewHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimiter, apiKeyService apikey.APIKeyService) *Handler { h := &Handler{ - Router: mux.NewRouter(), + Router: mux.NewRouter(), + bouncer: bouncer, + apiKeyService: apiKeyService, } h.Handle("/users", bouncer.AdminAccess(httperror.LoggerHandler(h.userCreate))).Methods(http.MethodPost) @@ -46,6 +51,12 @@ func NewHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimi bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.userUpdate))).Methods(http.MethodPut) h.Handle("/users/{id}", bouncer.AdminAccess(httperror.LoggerHandler(h.userDelete))).Methods(http.MethodDelete) + h.Handle("/users/{id}/tokens", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.userGetAccessTokens))).Methods(http.MethodGet) + h.Handle("/users/{id}/tokens", + rateLimiter.LimitAccess(bouncer.RestrictedAccess(httperror.LoggerHandler(h.userCreateAccessToken)))).Methods(http.MethodPost) + h.Handle("/users/{id}/tokens/{keyID}", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.userRemoveAccessToken))).Methods(http.MethodDelete) h.Handle("/users/{id}/memberships", bouncer.RestrictedAccess(httperror.LoggerHandler(h.userMemberships))).Methods(http.MethodGet) h.Handle("/users/{id}/passwd", diff --git a/api/http/handler/users/user_create.go b/api/http/handler/users/user_create.go index e44328074..ee2bcc240 100644 --- a/api/http/handler/users/user_create.go +++ b/api/http/handler/users/user_create.go @@ -39,6 +39,7 @@ func (payload *userCreatePayload) Validate(r *http.Request) error { // @description Only administrators can create an administrator user account. // @description **Access policy**: restricted // @tags users +// @security ApiKeyAuth // @security jwt // @accept json // @produce json diff --git a/api/http/handler/users/user_create_access_token.go b/api/http/handler/users/user_create_access_token.go new file mode 100644 index 000000000..ad8fb880d --- /dev/null +++ b/api/http/handler/users/user_create_access_token.go @@ -0,0 +1,94 @@ +package users + +import ( + "errors" + "net/http" + + "github.com/asaskevich/govalidator" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + portainer "github.com/portainer/portainer/api" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" +) + +type userAccessTokenCreatePayload struct { + Description string `validate:"required" example:"github-api-key" json:"description"` +} + +func (payload *userAccessTokenCreatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Description) { + return errors.New("invalid description. cannot be empty") + } + if govalidator.HasWhitespaceOnly(payload.Description) { + return errors.New("invalid description. cannot contain only whitespaces") + } + if govalidator.MinStringLength(payload.Description, "128") { + return errors.New("invalid description. cannot be longer than 128 characters") + } + return nil +} + +type accessTokenResponse struct { + RawAPIKey string `json:"rawAPIKey"` + APIKey portainer.APIKey `json:"apiKey"` +} + +// @id UserGenerateAPIKey +// @summary Generate an API key for a user +// @description Generates an API key for a user. +// @description Only the calling user can generate a token for themselves. +// @description **Access policy**: restricted +// @tags users +// @security jwt +// @accept json +// @produce json +// @param id path int true "User identifier" +// @param body body userAccessTokenCreatePayload true "details" +// @success 201 {object} accessTokenResponse "Created" +// @failure 400 "Invalid request" +// @failure 401 "Unauthorized" +// @failure 403 "Permission denied" +// @failure 404 "User not found" +// @failure 500 "Server error" +// @router /users/{id}/tokens [post] +func (handler *Handler) userCreateAccessToken(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + // specifically require JWT auth for this endpoint since API-Key based auth is not supported + if jwt := handler.bouncer.JWTAuthLookup(r); jwt == nil { + return &httperror.HandlerError{http.StatusUnauthorized, "Auth not supported", errors.New("JWT Authentication required")} + } + + var payload userAccessTokenCreatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + userID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid user identifier route variable", err} + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} + } + + if tokenData.ID != portainer.UserID(userID) { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to create user access token", httperrors.ErrUnauthorized} + } + + user, err := handler.DataStore.User().User(portainer.UserID(userID)) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Unable to find a user", err} + } + + rawAPIKey, apiKey, err := handler.apiKeyService.GenerateApiKey(*user, payload.Description) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Internal Server Error", err} + } + + w.WriteHeader(http.StatusCreated) + return response.JSON(w, accessTokenResponse{rawAPIKey, *apiKey}) +} diff --git a/api/http/handler/users/user_create_access_token_test.go b/api/http/handler/users/user_create_access_token_test.go new file mode 100644 index 000000000..19a697906 --- /dev/null +++ b/api/http/handler/users/user_create_access_token_test.go @@ -0,0 +1,156 @@ +package users + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/apikey" + "github.com/portainer/portainer/api/bolt" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/jwt" + "github.com/stretchr/testify/assert" +) + +func Test_userCreateAccessToken(t *testing.T) { + is := assert.New(t) + + store, teardown := bolt.MustNewTestStore(true) + defer teardown() + + // create admin and standard user(s) + adminUser := &portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole} + err := store.User().CreateUser(adminUser) + is.NoError(err, "error creating admin user") + + user := &portainer.User{ID: 2, Username: "standard", Role: portainer.StandardUserRole} + err = store.User().CreateUser(user) + is.NoError(err, "error creating user") + + // setup services + jwtService, err := jwt.NewService("1h", store) + is.NoError(err, "Error initiating jwt service") + apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User()) + requestBouncer := security.NewRequestBouncer(store, jwtService, apiKeyService) + rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour) + + h := NewHandler(requestBouncer, rateLimiter, apiKeyService) + h.DataStore = store + + // generate standard and admin user tokens + adminJWT, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role}) + jwt, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user.ID, Username: user.Username, Role: user.Role}) + + t.Run("standard user successfully generates API key", func(t *testing.T) { + data := userAccessTokenCreatePayload{Description: "test-token"} + payload, err := json.Marshal(data) + is.NoError(err) + + req := httptest.NewRequest(http.MethodPost, "/users/2/tokens", bytes.NewBuffer(payload)) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt)) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusCreated, rr.Code) + + body, err := io.ReadAll(rr.Body) + is.NoError(err, "ReadAll should not return error") + + var resp accessTokenResponse + err = json.Unmarshal(body, &resp) + is.NoError(err, "response should be json") + is.EqualValues(data.Description, resp.APIKey.Description) + is.NotEmpty(resp.RawAPIKey) + }) + + t.Run("admin cannot generate API key for standard user", func(t *testing.T) { + data := userAccessTokenCreatePayload{Description: "test-token-admin"} + payload, err := json.Marshal(data) + is.NoError(err) + + req := httptest.NewRequest(http.MethodPost, "/users/2/tokens", bytes.NewBuffer(payload)) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT)) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusForbidden, rr.Code) + + _, err = io.ReadAll(rr.Body) + is.NoError(err, "ReadAll should not return error") + }) + + t.Run("endpoint cannot generate api-key using api-key auth", func(t *testing.T) { + rawAPIKey, _, err := apiKeyService.GenerateApiKey(*user, "test-api-key") + is.NoError(err) + + data := userAccessTokenCreatePayload{Description: "test-token-fails"} + payload, err := json.Marshal(data) + is.NoError(err) + + req := httptest.NewRequest(http.MethodPost, "/users/2/tokens", bytes.NewBuffer(payload)) + req.Header.Add("x-api-key", rawAPIKey) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusUnauthorized, rr.Code) + + body, err := io.ReadAll(rr.Body) + is.NoError(err, "ReadAll should not return error") + is.Equal("{\"message\":\"Auth not supported\",\"details\":\"JWT Authentication required\"}\n", string(body)) + }) +} + +func Test_userAccessTokenCreatePayload(t *testing.T) { + is := assert.New(t) + + tests := []struct { + payload userAccessTokenCreatePayload + shouldFail bool + }{ + { + payload: userAccessTokenCreatePayload{Description: "test-token"}, + shouldFail: false, + }, + { + payload: userAccessTokenCreatePayload{Description: ""}, + shouldFail: true, + }, + { + payload: userAccessTokenCreatePayload{Description: "test token"}, + shouldFail: false, + }, + { + payload: userAccessTokenCreatePayload{Description: "test-token "}, + shouldFail: false, + }, + { + payload: userAccessTokenCreatePayload{Description: ` +this string is longer than 128 characters and hence this will fail. +this string is longer than 128 characters and hence this will fail. +this string is longer than 128 characters and hence this will fail. +this string is longer than 128 characters and hence this will fail. +this string is longer than 128 characters and hence this will fail. +this string is longer than 128 characters and hence this will fail. +`}, + shouldFail: true, + }, + } + + for _, test := range tests { + err := test.payload.Validate(nil) + if test.shouldFail { + is.Error(err) + } else { + is.NoError(err) + } + } +} diff --git a/api/http/handler/users/user_delete.go b/api/http/handler/users/user_delete.go index 493421437..f8acffb21 100644 --- a/api/http/handler/users/user_delete.go +++ b/api/http/handler/users/user_delete.go @@ -17,6 +17,7 @@ import ( // @description Remove a user. // @description **Access policy**: administrator // @tags users +// @security ApiKeyAuth // @security jwt // @produce json // @param id path int true "User identifier" @@ -94,5 +95,17 @@ func (handler *Handler) deleteUser(w http.ResponseWriter, user *portainer.User) return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove user memberships from the database", err} } + // Remove all of the users persisted API keys + apiKeys, err := handler.apiKeyService.GetAPIKeys(user.ID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user API keys from the database", err} + } + for _, k := range apiKeys { + err = handler.apiKeyService.DeleteAPIKey(k.ID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove user API key from the database", err} + } + } + return response.Empty(w) } diff --git a/api/http/handler/users/user_delete_test.go b/api/http/handler/users/user_delete_test.go new file mode 100644 index 000000000..d3884f19b --- /dev/null +++ b/api/http/handler/users/user_delete_test.go @@ -0,0 +1,56 @@ +package users + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/apikey" + "github.com/portainer/portainer/api/bolt" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/jwt" + "github.com/stretchr/testify/assert" +) + +func Test_deleteUserRemovesAccessTokens(t *testing.T) { + is := assert.New(t) + + store, teardown := bolt.MustNewTestStore(true) + defer teardown() + + // create standard user + user := &portainer.User{ID: 2, Username: "standard", Role: portainer.StandardUserRole} + err := store.User().CreateUser(user) + is.NoError(err, "error creating user") + + // setup services + jwtService, err := jwt.NewService("1h", store) + is.NoError(err, "Error initiating jwt service") + apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User()) + requestBouncer := security.NewRequestBouncer(store, jwtService, apiKeyService) + rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour) + + h := NewHandler(requestBouncer, rateLimiter, apiKeyService) + h.DataStore = store + + t.Run("standard user deletion removes all associated access tokens", func(t *testing.T) { + _, _, err := apiKeyService.GenerateApiKey(*user, "test-user-token") + is.NoError(err) + + keys, err := apiKeyService.GetAPIKeys(user.ID) + is.NoError(err) + is.Len(keys, 1) + + rr := httptest.NewRecorder() + + h.deleteUser(rr, user) + + is.Equal(http.StatusNoContent, rr.Code) + + keys, err = apiKeyService.GetAPIKeys(user.ID) + is.NoError(err) + is.Equal(0, len(keys)) + }) +} diff --git a/api/http/handler/users/user_get_access_tokens.go b/api/http/handler/users/user_get_access_tokens.go new file mode 100644 index 000000000..8d86a7ff4 --- /dev/null +++ b/api/http/handler/users/user_get_access_tokens.go @@ -0,0 +1,69 @@ +package users + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + portainer "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" +) + +// @id UserGetAPIKeys +// @summary Get all API keys for a user +// @description Gets all API keys for a user. +// @description Only the calling user or admin can retrieve api-keys. +// @description **Access policy**: authenticated +// @tags users +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param id path int true "User identifier" +// @success 200 {array} portainer.APIKey "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 "User not found" +// @failure 500 "Server error" +// @router /users/{id}/tokens [get] +func (handler *Handler) userGetAccessTokens(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + userID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid user identifier route variable", err} + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} + } + + if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to get user access tokens", httperrors.ErrUnauthorized} + } + + _, err = handler.DataStore.User().User(portainer.UserID(userID)) + if err != nil { + if err == bolterrors.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a user with the specified identifier inside the database", err} + } + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a user with the specified identifier inside the database", err} + } + + apiKeys, err := handler.apiKeyService.GetAPIKeys(portainer.UserID(userID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Internal Server Error", err} + } + + for idx := range apiKeys { + hideAPIKeyFields(&apiKeys[idx]) + } + + return response.JSON(w, apiKeys) +} + +// hideAPIKeyFields remove the digest from the API key (it is not needed in the response) +func hideAPIKeyFields(apiKey *portainer.APIKey) { + apiKey.Digest = nil +} diff --git a/api/http/handler/users/user_get_access_tokens_test.go b/api/http/handler/users/user_get_access_tokens_test.go new file mode 100644 index 000000000..3fe515590 --- /dev/null +++ b/api/http/handler/users/user_get_access_tokens_test.go @@ -0,0 +1,137 @@ +package users + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/apikey" + "github.com/portainer/portainer/api/bolt" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/jwt" + "github.com/stretchr/testify/assert" +) + +func Test_userGetAccessTokens(t *testing.T) { + is := assert.New(t) + + store, teardown := bolt.MustNewTestStore(true) + defer teardown() + + // create admin and standard user(s) + adminUser := &portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole} + err := store.User().CreateUser(adminUser) + is.NoError(err, "error creating admin user") + + user := &portainer.User{ID: 2, Username: "standard", Role: portainer.StandardUserRole} + err = store.User().CreateUser(user) + is.NoError(err, "error creating user") + + // setup services + jwtService, err := jwt.NewService("1h", store) + is.NoError(err, "Error initiating jwt service") + apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User()) + requestBouncer := security.NewRequestBouncer(store, jwtService, apiKeyService) + rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour) + + h := NewHandler(requestBouncer, rateLimiter, apiKeyService) + h.DataStore = store + + // generate standard and admin user tokens + adminJWT, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role}) + jwt, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user.ID, Username: user.Username, Role: user.Role}) + + t.Run("standard user can successfully retrieve API key", func(t *testing.T) { + _, apiKey, err := apiKeyService.GenerateApiKey(*user, "test-get-token") + is.NoError(err) + + req := httptest.NewRequest(http.MethodGet, "/users/2/tokens", nil) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt)) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code) + + body, err := io.ReadAll(rr.Body) + is.NoError(err, "ReadAll should not return error") + + var resp []portainer.APIKey + err = json.Unmarshal(body, &resp) + is.NoError(err, "response should be list json") + + is.Len(resp, 1) + if len(resp) == 1 { + is.Nil(resp[0].Digest) + is.Equal(apiKey.ID, resp[0].ID) + is.Equal(apiKey.UserID, resp[0].UserID) + is.Equal(apiKey.Prefix, resp[0].Prefix) + is.Equal(apiKey.Description, resp[0].Description) + } + }) + + t.Run("admin can retrieve standard user API Key", func(t *testing.T) { + _, _, err := apiKeyService.GenerateApiKey(*user, "test-get-admin-token") + is.NoError(err) + + req := httptest.NewRequest(http.MethodGet, "/users/2/tokens", nil) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT)) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code) + + body, err := io.ReadAll(rr.Body) + is.NoError(err, "ReadAll should not return error") + + var resp []portainer.APIKey + err = json.Unmarshal(body, &resp) + is.NoError(err, "response should be list json") + + is.True(len(resp) > 0) + }) + + t.Run("user can retrieve API Key using api-key auth", func(t *testing.T) { + rawAPIKey, _, err := apiKeyService.GenerateApiKey(*user, "test-api-key") + is.NoError(err) + + req := httptest.NewRequest(http.MethodGet, "/users/2/tokens", nil) + req.Header.Add("x-api-key", rawAPIKey) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code) + + body, err := io.ReadAll(rr.Body) + is.NoError(err, "ReadAll should not return error") + + var resp []portainer.APIKey + err = json.Unmarshal(body, &resp) + is.NoError(err, "response should be list json") + + is.True(len(resp) > 0) + }) +} + +func Test_hideAPIKeyFields(t *testing.T) { + is := assert.New(t) + + apiKey := &portainer.APIKey{ + ID: 1, + UserID: 2, + Prefix: "abc", + Description: "test", + Digest: nil, + } + + hideAPIKeyFields(apiKey) + + is.Nil(apiKey.Digest, "digest should be cleared when hiding api key fields") +} diff --git a/api/http/handler/users/user_inspect.go b/api/http/handler/users/user_inspect.go index 61195d372..a973a9932 100644 --- a/api/http/handler/users/user_inspect.go +++ b/api/http/handler/users/user_inspect.go @@ -18,6 +18,7 @@ import ( // @description User passwords are filtered out, and should never be accessible. // @description **Access policy**: authenticated // @tags users +// @security ApiKeyAuth // @security jwt // @produce json // @param id path int true "User identifier" diff --git a/api/http/handler/users/user_list.go b/api/http/handler/users/user_list.go index 57e609d06..869218342 100644 --- a/api/http/handler/users/user_list.go +++ b/api/http/handler/users/user_list.go @@ -15,6 +15,7 @@ import ( // @description User passwords are filtered out, and should never be accessible. // @description **Access policy**: restricted // @tags users +// @security ApiKeyAuth // @security jwt // @produce json // @success 200 {array} portainer.User "Success" diff --git a/api/http/handler/users/user_memberships.go b/api/http/handler/users/user_memberships.go index 271c36e43..82a1b366e 100644 --- a/api/http/handler/users/user_memberships.go +++ b/api/http/handler/users/user_memberships.go @@ -16,6 +16,7 @@ import ( // @description Inspect a user memberships. // @description **Access policy**: restricted // @tags users +// @security ApiKeyAuth // @security jwt // @produce json // @param id path int true "User identifier" diff --git a/api/http/handler/users/user_remove_access_token.go b/api/http/handler/users/user_remove_access_token.go new file mode 100644 index 000000000..e94af286f --- /dev/null +++ b/api/http/handler/users/user_remove_access_token.go @@ -0,0 +1,68 @@ +package users + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/apikey" + bolterrors "github.com/portainer/portainer/api/bolt/errors" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" +) + +// @id UserRemoveAPIKey +// @summary Remove an api-key associated to a user +// @description Remove an api-key associated to a user.. +// @description Only the calling user or admin can remove api-key. +// @description **Access policy**: authenticated +// @tags users +// @security ApiKeyAuth +// @security jwt +// @param id path int true "User identifier" +// @param keyID path int true "Api Key identifier" +// @success 204 "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 "Not found" +// @failure 500 "Server error" +// @router /users/{id}/tokens/{keyID} [delete] +func (handler *Handler) userRemoveAccessToken(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + userID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid user identifier route variable", err} + } + + apiKeyID, err := request.RetrieveNumericRouteVariableValue(r, "keyID") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid api-key identifier route variable", err} + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} + } + if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to get user access tokens", httperrors.ErrUnauthorized} + } + + _, err = handler.DataStore.User().User(portainer.UserID(userID)) + if err != nil { + if err == bolterrors.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a user with the specified identifier inside the database", err} + } + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a user with the specified identifier inside the database", err} + } + + err = handler.apiKeyService.DeleteAPIKey(portainer.APIKeyID(apiKeyID)) + if err != nil { + if err == apikey.ErrInvalidAPIKey { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an api-key with the specified identifier inside the database", err} + } + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the api-key from the user", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/users/user_remove_access_token_test.go b/api/http/handler/users/user_remove_access_token_test.go new file mode 100644 index 000000000..20174fcb1 --- /dev/null +++ b/api/http/handler/users/user_remove_access_token_test.go @@ -0,0 +1,100 @@ +package users + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/apikey" + "github.com/portainer/portainer/api/bolt" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/jwt" + "github.com/stretchr/testify/assert" +) + +func Test_userRemoveAccessToken(t *testing.T) { + is := assert.New(t) + + store, teardown := bolt.MustNewTestStore(true) + defer teardown() + + // create admin and standard user(s) + adminUser := &portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole} + err := store.User().CreateUser(adminUser) + is.NoError(err, "error creating admin user") + + user := &portainer.User{ID: 2, Username: "standard", Role: portainer.StandardUserRole} + err = store.User().CreateUser(user) + is.NoError(err, "error creating user") + + // setup services + jwtService, err := jwt.NewService("1h", store) + is.NoError(err, "Error initiating jwt service") + apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User()) + requestBouncer := security.NewRequestBouncer(store, jwtService, apiKeyService) + rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour) + + h := NewHandler(requestBouncer, rateLimiter, apiKeyService) + h.DataStore = store + + // generate standard and admin user tokens + adminJWT, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role}) + jwt, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user.ID, Username: user.Username, Role: user.Role}) + + t.Run("standard user can successfully delete API key", func(t *testing.T) { + _, apiKey, err := apiKeyService.GenerateApiKey(*user, "test-delete-token") + is.NoError(err) + + req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("%s/%d", "/users/2/tokens", apiKey.ID), nil) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt)) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusNoContent, rr.Code) + + keys, err := apiKeyService.GetAPIKeys(user.ID) + is.NoError(err) + + is.Equal(0, len(keys)) + }) + + t.Run("admin can delete a standard user API Key", func(t *testing.T) { + _, apiKey, err := apiKeyService.GenerateApiKey(*user, "test-admin-delete-token") + is.NoError(err) + + req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("%s/%d", "/users/2/tokens", apiKey.ID), nil) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT)) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusNoContent, rr.Code) + + keys, err := apiKeyService.GetAPIKeys(user.ID) + is.NoError(err) + + is.Equal(0, len(keys)) + }) + + t.Run("user can delete API Key using api-key auth", func(t *testing.T) { + rawAPIKey, apiKey, err := apiKeyService.GenerateApiKey(*user, "test-api-key-auth-deletion") + is.NoError(err) + + req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("%s/%d", "/users/2/tokens", apiKey.ID), nil) + req.Header.Add("x-api-key", rawAPIKey) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusNoContent, rr.Code) + + keys, err := apiKeyService.GetAPIKeys(user.ID) + is.NoError(err) + + is.Equal(0, len(keys)) + }) +} diff --git a/api/http/handler/users/user_update.go b/api/http/handler/users/user_update.go index 02e337f3b..4db2629d1 100644 --- a/api/http/handler/users/user_update.go +++ b/api/http/handler/users/user_update.go @@ -15,8 +15,8 @@ import ( ) type userUpdatePayload struct { - Username string `validate:"required" example:"bob"` - Password string `validate:"required" example:"cg9Wgky3"` + Username string `validate:"required" example:"bob"` + Password string `validate:"required" example:"cg9Wgky3"` UserTheme string `example:"dark"` // User role (1 for administrator account and 2 for regular account) Role int `validate:"required" enums:"1,2" example:"2"` @@ -38,6 +38,7 @@ func (payload *userUpdatePayload) Validate(r *http.Request) error { // @description Update user details. A regular user account can only update his details. // @description **Access policy**: authenticated // @tags users +// @security ApiKeyAuth // @security jwt // @accept json // @produce json @@ -114,5 +115,8 @@ func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *http return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user changes inside the database", err} } + // remove all of the users persisted API keys + handler.apiKeyService.InvalidateUserKeyCache(user.ID) + return response.JSON(w, user) } diff --git a/api/http/handler/users/user_update_password.go b/api/http/handler/users/user_update_password.go index c1e7d1e5e..59788cc31 100644 --- a/api/http/handler/users/user_update_password.go +++ b/api/http/handler/users/user_update_password.go @@ -36,6 +36,7 @@ func (payload *userUpdatePasswordPayload) Validate(r *http.Request) error { // @description Update password for the specified user. // @description **Access policy**: authenticated // @tags users +// @security ApiKeyAuth // @security jwt // @accept json // @produce json diff --git a/api/http/handler/users/user_update_test.go b/api/http/handler/users/user_update_test.go new file mode 100644 index 000000000..f59b4b6a6 --- /dev/null +++ b/api/http/handler/users/user_update_test.go @@ -0,0 +1,56 @@ +package users + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/apikey" + "github.com/portainer/portainer/api/bolt" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/jwt" + "github.com/stretchr/testify/assert" +) + +func Test_updateUserRemovesAccessTokens(t *testing.T) { + is := assert.New(t) + + store, teardown := bolt.MustNewTestStore(true) + defer teardown() + + // create standard user + user := &portainer.User{ID: 2, Username: "standard", Role: portainer.StandardUserRole} + err := store.User().CreateUser(user) + is.NoError(err, "error creating user") + + // setup services + jwtService, err := jwt.NewService("1h", store) + is.NoError(err, "Error initiating jwt service") + apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User()) + requestBouncer := security.NewRequestBouncer(store, jwtService, apiKeyService) + rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour) + + h := NewHandler(requestBouncer, rateLimiter, apiKeyService) + h.DataStore = store + + t.Run("standard user deletion removes all associated access tokens", func(t *testing.T) { + _, _, err := apiKeyService.GenerateApiKey(*user, "test-user-token") + is.NoError(err) + + keys, err := apiKeyService.GetAPIKeys(user.ID) + is.NoError(err) + is.Len(keys, 1) + + rr := httptest.NewRecorder() + + h.deleteUser(rr, user) + + is.Equal(http.StatusNoContent, rr.Code) + + keys, err = apiKeyService.GetAPIKeys(user.ID) + is.NoError(err) + is.Equal(0, len(keys)) + }) +} diff --git a/api/http/handler/webhooks/webhook_create.go b/api/http/handler/webhooks/webhook_create.go index 746fcd6ed..ba3ba558c 100644 --- a/api/http/handler/webhooks/webhook_create.go +++ b/api/http/handler/webhooks/webhook_create.go @@ -34,6 +34,7 @@ func (payload *webhookCreatePayload) Validate(r *http.Request) error { // @summary Create a webhook // @description **Access policy**: authenticated +// @security ApiKeyAuth // @security jwt // @tags webhooks // @accept json diff --git a/api/http/handler/webhooks/webhook_delete.go b/api/http/handler/webhooks/webhook_delete.go index 40c09e521..bd94ce9a5 100644 --- a/api/http/handler/webhooks/webhook_delete.go +++ b/api/http/handler/webhooks/webhook_delete.go @@ -11,6 +11,7 @@ import ( // @summary Delete a webhook // @description **Access policy**: authenticated +// @security ApiKeyAuth // @security jwt // @tags webhooks // @param id path int true "Webhook id" diff --git a/api/http/handler/webhooks/webhook_list.go b/api/http/handler/webhooks/webhook_list.go index 1206530a3..a6f26889d 100644 --- a/api/http/handler/webhooks/webhook_list.go +++ b/api/http/handler/webhooks/webhook_list.go @@ -16,6 +16,7 @@ type webhookListOperationFilters struct { // @summary List webhooks // @description **Access policy**: authenticated +// @security ApiKeyAuth // @security jwt // @tags webhooks // @accept json diff --git a/api/http/handler/websocket/attach.go b/api/http/handler/websocket/attach.go index c41f85269..ea0109403 100644 --- a/api/http/handler/websocket/attach.go +++ b/api/http/handler/websocket/attach.go @@ -19,6 +19,7 @@ import ( // @description If the nodeName query parameter is not specified, the request will be upgraded to the websocket protocol and // @description an AttachStart operation HTTP request will be created and hijacked. // @description **Access policy**: authenticated +// @security ApiKeyAuth // @security jwt // @tags websocket // @accept json diff --git a/api/http/handler/websocket/exec.go b/api/http/handler/websocket/exec.go index 544b60cd2..2cd1e46f6 100644 --- a/api/http/handler/websocket/exec.go +++ b/api/http/handler/websocket/exec.go @@ -27,6 +27,7 @@ type execStartOperationPayload struct { // @description If the nodeName query parameter is not specified, the request will be upgraded to the websocket protocol and // @description an ExecStart operation HTTP request will be created and hijacked. // @**Access policy**: authenticated +// @security ApiKeyAuth // @security jwt // @tags websocket // @accept json diff --git a/api/http/handler/websocket/pod.go b/api/http/handler/websocket/pod.go index 5e2bb6952..330779417 100644 --- a/api/http/handler/websocket/pod.go +++ b/api/http/handler/websocket/pod.go @@ -20,6 +20,7 @@ import ( // @summary Execute a websocket on pod // @description The request will be upgraded to the websocket protocol. // @description **Access policy**: authenticated +// @security ApiKeyAuth // @security jwt // @tags websocket // @accept json diff --git a/api/http/handler/websocket/shell_pod.go b/api/http/handler/websocket/shell_pod.go index 97d876784..87e2ae23b 100644 --- a/api/http/handler/websocket/shell_pod.go +++ b/api/http/handler/websocket/shell_pod.go @@ -13,6 +13,7 @@ import ( // @summary Execute a websocket on kubectl shell pod // @description The request will be upgraded to the websocket protocol. The request will proxy input from the client to the pod via long-lived websocket connection. // @description **Access policy**: authenticated +// @security ApiKeyAuth // @security jwt // @tags websocket // @accept json diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go index d4c772284..395f3f450 100644 --- a/api/http/security/bouncer.go +++ b/api/http/security/bouncer.go @@ -4,9 +4,11 @@ import ( "errors" "net/http" "strings" + "time" httperror "github.com/portainer/libhttp/error" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/apikey" bolterrors "github.com/portainer/portainer/api/bolt/errors" httperrors "github.com/portainer/portainer/api/http/errors" ) @@ -14,8 +16,9 @@ import ( type ( // RequestBouncer represents an entity that manages API request accesses RequestBouncer struct { - dataStore portainer.DataStore - jwtService portainer.JWTService + dataStore portainer.DataStore + jwtService portainer.JWTService + apiKeyService apikey.APIKeyService } // RestrictedRequestContext is a data structure containing information @@ -26,13 +29,19 @@ type ( UserID portainer.UserID UserMemberships []portainer.TeamMembership } + + // tokenLookup looks up a token in the request + tokenLookup func(*http.Request) *portainer.TokenData ) +const apiKeyHeader = "X-API-KEY" + // NewRequestBouncer initializes a new RequestBouncer -func NewRequestBouncer(dataStore portainer.DataStore, jwtService portainer.JWTService) *RequestBouncer { +func NewRequestBouncer(dataStore portainer.DataStore, jwtService portainer.JWTService, apiKeyService apikey.APIKeyService) *RequestBouncer { return &RequestBouncer{ - dataStore: dataStore, - jwtService: jwtService, + dataStore: dataStore, + jwtService: jwtService, + apiKeyService: apiKeyService, } } @@ -128,11 +137,14 @@ func (bouncer *RequestBouncer) AuthorizedEdgeEndpointOperation(r *http.Request, return nil } -// handlers are applied backwards to the incoming request: -// - add secure handlers to the response -// - parse the JWT token and put it into the http context. +// mwAuthenticatedUser authenticates a request by +// - adding a secure handlers to the response +// - authenticating the request with a valid token func (bouncer *RequestBouncer) mwAuthenticatedUser(h http.Handler) http.Handler { - h = bouncer.mwCheckAuthentication(h) + h = bouncer.mwAuthenticateFirst([]tokenLookup{ + bouncer.JWTAuthLookup, + bouncer.apiKeyLookup, + }, h) h = mwSecureHeaders(h) return h } @@ -193,42 +205,90 @@ func (bouncer *RequestBouncer) mwUpgradeToRestrictedRequest(next http.Handler) h }) } -// mwCheckAuthentication provides Authentication middleware for handlers -// -// It parses the JWT token and adds the parsed token data to the http context -func (bouncer *RequestBouncer) mwCheckAuthentication(next http.Handler) http.Handler { +// mwAuthenticateFirst authenticates a request an auth token. +// A result of a first succeded token lookup would be used for the authentication. +func (bouncer *RequestBouncer) mwAuthenticateFirst(tokenLookups []tokenLookup, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var tokenData *portainer.TokenData + var token *portainer.TokenData - // get token from the Authorization header or query parameter - token, err := ExtractBearerToken(r) - if err != nil { - httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", err) + for _, lookup := range tokenLookups { + token = lookup(r) + + if token != nil { + break + } + } + + if token == nil { + httperror.WriteError(w, http.StatusUnauthorized, "A valid authorisation token is missing", httperrors.ErrUnauthorized) return } - tokenData, err = bouncer.jwtService.ParseAndVerifyToken(token) - if err != nil { - httperror.WriteError(w, http.StatusUnauthorized, "Invalid JWT token", err) + user, _ := bouncer.dataStore.User().User(token.ID) + if user == nil { + httperror.WriteError(w, http.StatusUnauthorized, "An authorisation token is invalid", httperrors.ErrUnauthorized) return } - _, err = bouncer.dataStore.User().User(tokenData.ID) - if err != nil && err == bolterrors.ErrObjectNotFound { - httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", httperrors.ErrUnauthorized) - return - } else if err != nil { - httperror.WriteError(w, http.StatusInternalServerError, "Unable to retrieve user details from the database", err) - return - } - - ctx := StoreTokenData(r, tokenData) + ctx := StoreTokenData(r, token) next.ServeHTTP(w, r.WithContext(ctx)) }) } -// ExtractBearerToken extracts the Bearer token from the request header or query parameter and returns the token. -func ExtractBearerToken(r *http.Request) (string, error) { +// JWTAuthLookup looks up a valid bearer in the request. +func (bouncer *RequestBouncer) JWTAuthLookup(r *http.Request) *portainer.TokenData { + // get token from the Authorization header or query parameter + token, err := extractBearerToken(r) + if err != nil { + return nil + } + + tokenData, err := bouncer.jwtService.ParseAndVerifyToken(token) + if err != nil { + return nil + } + + return tokenData +} + +// apiKeyLookup looks up an verifies an api-key by: +// - computing the digest of the raw api-key +// - verifying it exists in cache/database +// - matching the key to a user (ID, Role) +// If the key is valid/verified, the last updated time of the key is updated. +// Successful verification of the key will return a TokenData object - since the downstream handlers +// utilise the token injected in the request context. +func (bouncer *RequestBouncer) apiKeyLookup(r *http.Request) *portainer.TokenData { + rawAPIKey, ok := extractAPIKey(r) + if !ok { + return nil + } + + digest := bouncer.apiKeyService.HashRaw(rawAPIKey) + + user, apiKey, err := bouncer.apiKeyService.GetDigestUserAndKey(digest) + if err != nil { + return nil + } + + tokenData := &portainer.TokenData{ + ID: user.ID, + Username: user.Username, + Role: user.Role, + } + if _, err := bouncer.jwtService.GenerateToken(tokenData); err != nil { + return nil + } + + // update the last used time of the key + apiKey.LastUsed = time.Now().UTC().Unix() + bouncer.apiKeyService.UpdateAPIKey(&apiKey) + + return tokenData +} + +// extractBearerToken extracts the Bearer token from the request header or query parameter and returns the token. +func extractBearerToken(r *http.Request) (string, error) { // Optionally, token might be set via the "token" query parameter. // For example, in websocket requests token := r.URL.Query().Get("token") @@ -244,6 +304,26 @@ func ExtractBearerToken(r *http.Request) (string, error) { return token, nil } +// extractAPIKey extracts the api key from the api key request header or query params. +func extractAPIKey(r *http.Request) (apikey string, ok bool) { + // extract the API key from the request header + apikey = r.Header.Get(apiKeyHeader) + if apikey != "" { + return apikey, true + } + + // extract the API key from query params. + // Case-insensitive check for the "X-API-KEY" query param. + query := r.URL.Query() + for k, v := range query { + if strings.EqualFold(k, apiKeyHeader) { + return v[0], true + } + } + + return "", false +} + // mwSecureHeaders provides secure headers middleware for handlers. func mwSecureHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/api/http/security/bouncer_test.go b/api/http/security/bouncer_test.go new file mode 100644 index 000000000..403547fab --- /dev/null +++ b/api/http/security/bouncer_test.go @@ -0,0 +1,334 @@ +package security + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/apikey" + "github.com/portainer/portainer/api/bolt" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/jwt" + "github.com/stretchr/testify/assert" +) + +// testHandler200 is a simple handler which returns HTTP status 200 OK +var testHandler200 = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) +}) + +func tokenLookupSucceed(dataStore portainer.DataStore, jwtService portainer.JWTService) tokenLookup { + return func(r *http.Request) *portainer.TokenData { + uid := portainer.UserID(1) + dataStore.User().CreateUser(&portainer.User{ID: uid}) + jwtService.GenerateToken(&portainer.TokenData{ID: uid}) + return &portainer.TokenData{ID: 1} + } +} + +func tokenLookupFail(r *http.Request) *portainer.TokenData { + return nil +} + +func Test_mwAuthenticateFirst(t *testing.T) { + is := assert.New(t) + + store, teardown := bolt.MustNewTestStore(true) + defer teardown() + + jwtService, err := jwt.NewService("1h", store) + assert.NoError(t, err, "failed to create a copy of service") + + apiKeyService := apikey.NewAPIKeyService(nil, nil) + + bouncer := NewRequestBouncer(store, jwtService, apiKeyService) + + tests := []struct { + name string + verificationMiddlwares []tokenLookup + wantStatusCode int + }{ + { + name: "mwAuthenticateFirst middleware passes with no middleware", + verificationMiddlwares: nil, + wantStatusCode: http.StatusUnauthorized, + }, + { + name: "mwAuthenticateFirst middleware succeeds with passing middleware", + verificationMiddlwares: []tokenLookup{ + tokenLookupSucceed(store, jwtService), + }, + wantStatusCode: http.StatusOK, + }, + { + name: "mwAuthenticateFirst fails with failing middleware", + verificationMiddlwares: []tokenLookup{ + tokenLookupFail, + }, + wantStatusCode: http.StatusUnauthorized, + }, + { + name: "mwAuthenticateFirst succeeds if first middleware successfully handles request", + verificationMiddlwares: []tokenLookup{ + tokenLookupSucceed(store, jwtService), + tokenLookupFail, + }, + wantStatusCode: http.StatusOK, + }, + { + name: "mwAuthenticateFirst succeeds if last middleware successfully handles request", + verificationMiddlwares: []tokenLookup{ + tokenLookupFail, + tokenLookupSucceed(store, jwtService), + }, + wantStatusCode: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rr := httptest.NewRecorder() + + h := bouncer.mwAuthenticateFirst(tt.verificationMiddlwares, testHandler200) + h.ServeHTTP(rr, req) + + is.Equal(tt.wantStatusCode, rr.Code, fmt.Sprintf("Status should be %d", tt.wantStatusCode)) + }) + } +} + +func Test_extractBearerToken(t *testing.T) { + is := assert.New(t) + + tt := []struct { + name string + requestHeader string + requestHeaderValue string + wantToken string + succeeds bool + }{ + { + name: "missing request header", + requestHeader: "", + requestHeaderValue: "", + wantToken: "", + succeeds: false, + }, + { + name: "invalid authorization request header", + requestHeader: "authorisation", // note: `s` + requestHeaderValue: "abc", + wantToken: "", + succeeds: false, + }, + { + name: "valid authorization request header", + requestHeader: "AUTHORIZATION", + requestHeaderValue: "abc", + wantToken: "abc", + succeeds: true, + }, + { + name: "valid authorization request header case-insensitive canonical check", + requestHeader: "authorization", + requestHeaderValue: "def", + wantToken: "def", + succeeds: true, + }, + } + + for _, test := range tt { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set(test.requestHeader, test.requestHeaderValue) + apiKey, err := extractBearerToken(req) + is.Equal(test.wantToken, apiKey) + if !test.succeeds { + is.Error(err, "Should return error") + is.ErrorIs(err, httperrors.ErrUnauthorized) + } else { + is.NoError(err) + } + } +} + +func Test_extractAPIKeyHeader(t *testing.T) { + is := assert.New(t) + + tt := []struct { + name string + requestHeader string + requestHeaderValue string + wantApiKey string + succeeds bool + }{ + { + name: "missing request header", + requestHeader: "", + requestHeaderValue: "", + wantApiKey: "", + succeeds: false, + }, + { + name: "invalid api-key request header", + requestHeader: "api-key", + requestHeaderValue: "abc", + wantApiKey: "", + succeeds: false, + }, + { + name: "valid api-key request header", + requestHeader: apiKeyHeader, + requestHeaderValue: "abc", + wantApiKey: "abc", + succeeds: true, + }, + { + name: "valid api-key request header case-insensitive canonical check", + requestHeader: "x-api-key", + requestHeaderValue: "def", + wantApiKey: "def", + succeeds: true, + }, + } + + for _, test := range tt { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set(test.requestHeader, test.requestHeaderValue) + apiKey, ok := extractAPIKey(req) + is.Equal(test.wantApiKey, apiKey) + is.Equal(test.succeeds, ok) + } +} + +func Test_extractAPIKeyQueryParam(t *testing.T) { + is := assert.New(t) + + tt := []struct { + name string + queryParam string + queryParamValue string + wantApiKey string + succeeds bool + }{ + { + name: "missing request header", + queryParam: "", + queryParamValue: "", + wantApiKey: "", + succeeds: false, + }, + { + name: "invalid api-key request header", + queryParam: "api-key", + queryParamValue: "abc", + wantApiKey: "", + succeeds: false, + }, + { + name: "valid api-key request header", + queryParam: apiKeyHeader, + queryParamValue: "abc", + wantApiKey: "abc", + succeeds: true, + }, + { + name: "valid api-key request header case-insensitive canonical check", + queryParam: "x-api-key", + queryParamValue: "def", + wantApiKey: "def", + succeeds: true, + }, + } + + for _, test := range tt { + req := httptest.NewRequest(http.MethodGet, "/", nil) + q := req.URL.Query() + q.Add(test.queryParam, test.queryParamValue) + req.URL.RawQuery = q.Encode() + + apiKey, ok := extractAPIKey(req) + is.Equal(test.wantApiKey, apiKey) + is.Equal(test.succeeds, ok) + } +} + +func Test_apiKeyLookup(t *testing.T) { + is := assert.New(t) + + store, teardown := bolt.MustNewTestStore(true) + defer teardown() + + // create standard user + user := &portainer.User{ID: 2, Username: "standard", Role: portainer.StandardUserRole} + err := store.User().CreateUser(user) + is.NoError(err, "error creating user") + + // setup services + jwtService, err := jwt.NewService("1h", store) + is.NoError(err, "Error initiating jwt service") + apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User()) + bouncer := NewRequestBouncer(store, jwtService, apiKeyService) + + t.Run("missing x-api-key header fails api-key lookup", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + // req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt)) + token := bouncer.apiKeyLookup(req) + is.Nil(token) + }) + + t.Run("invalid x-api-key header fails api-key lookup", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Add("x-api-key", "random-failing-api-key") + token := bouncer.apiKeyLookup(req) + is.Nil(token) + }) + + t.Run("valid x-api-key header succeeds api-key lookup", func(t *testing.T) { + rawAPIKey, _, err := apiKeyService.GenerateApiKey(*user, "test") + is.NoError(err) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Add("x-api-key", rawAPIKey) + + token := bouncer.apiKeyLookup(req) + + expectedToken := &portainer.TokenData{ID: user.ID, Username: user.Username, Role: portainer.StandardUserRole} + is.Equal(expectedToken, token) + }) + + t.Run("valid x-api-key header succeeds api-key lookup", func(t *testing.T) { + rawAPIKey, apiKey, err := apiKeyService.GenerateApiKey(*user, "test") + is.NoError(err) + defer apiKeyService.DeleteAPIKey(apiKey.ID) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Add("x-api-key", rawAPIKey) + + token := bouncer.apiKeyLookup(req) + + expectedToken := &portainer.TokenData{ID: user.ID, Username: user.Username, Role: portainer.StandardUserRole} + is.Equal(expectedToken, token) + }) + + t.Run("successful api-key lookup updates token last used time", func(t *testing.T) { + rawAPIKey, apiKey, err := apiKeyService.GenerateApiKey(*user, "test") + is.NoError(err) + defer apiKeyService.DeleteAPIKey(apiKey.ID) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Add("x-api-key", rawAPIKey) + + token := bouncer.apiKeyLookup(req) + + expectedToken := &portainer.TokenData{ID: user.ID, Username: user.Username, Role: portainer.StandardUserRole} + is.Equal(expectedToken, token) + + _, apiKeyUpdated, err := apiKeyService.GetDigestUserAndKey(apiKey.Digest) + is.NoError(err) + + is.True(apiKeyUpdated.LastUsed > apiKey.LastUsed) + }) +} diff --git a/api/http/server.go b/api/http/server.go index 67e87be51..f95944595 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -12,6 +12,7 @@ import ( "github.com/portainer/libhelm" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/adminmonitor" + "github.com/portainer/portainer/api/apikey" "github.com/portainer/portainer/api/crypto" "github.com/portainer/portainer/api/docker" "github.com/portainer/portainer/api/http/handler" @@ -77,6 +78,7 @@ type Server struct { DataStore portainer.DataStore GitService portainer.GitService OpenAMTService portainer.OpenAMTService + APIKeyService apikey.APIKeyService JWTService portainer.JWTService LDAPService portainer.LDAPService OAuthService portainer.OAuthService @@ -100,7 +102,7 @@ type Server struct { func (server *Server) Start() error { kubernetesTokenCacheManager := server.KubernetesTokenCacheManager - requestBouncer := security.NewRequestBouncer(server.DataStore, server.JWTService) + requestBouncer := security.NewRequestBouncer(server.DataStore, server.JWTService, server.APIKeyService) rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour) offlineGate := offlinegate.NewOfflineGate() @@ -175,7 +177,7 @@ func (server *Server) Start() error { var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public")) - var endpointHelmHandler = helm.NewHandler(requestBouncer, server.DataStore, server.KubernetesDeployer, server.HelmPackageManager, server.KubeConfigService) + var endpointHelmHandler = helm.NewHandler(requestBouncer, server.DataStore, server.JWTService, server.KubernetesDeployer, server.HelmPackageManager, server.KubeConfigService) var helmTemplatesHandler = helm.NewTemplateHandler(requestBouncer, server.HelmPackageManager) @@ -246,7 +248,7 @@ func (server *Server) Start() error { var uploadHandler = upload.NewHandler(requestBouncer) uploadHandler.FileService = server.FileService - var userHandler = users.NewHandler(requestBouncer, rateLimiter) + var userHandler = users.NewHandler(requestBouncer, rateLimiter, server.APIKeyService) userHandler.DataStore = server.DataStore userHandler.CryptoService = server.CryptoService diff --git a/api/internal/testhelpers/datastore.go b/api/internal/testhelpers/datastore.go index a62dc5366..ff2b60e1c 100644 --- a/api/internal/testhelpers/datastore.go +++ b/api/internal/testhelpers/datastore.go @@ -8,27 +8,28 @@ import ( ) type datastore struct { - customTemplate portainer.CustomTemplateService - edgeGroup portainer.EdgeGroupService - edgeJob portainer.EdgeJobService - edgeStack portainer.EdgeStackService - endpoint portainer.EndpointService - endpointGroup portainer.EndpointGroupService - endpointRelation portainer.EndpointRelationService - helmUserRepository portainer.HelmUserRepositoryService - registry portainer.RegistryService - resourceControl portainer.ResourceControlService - role portainer.RoleService - sslSettings portainer.SSLSettingsService - settings portainer.SettingsService - stack portainer.StackService - tag portainer.TagService - teamMembership portainer.TeamMembershipService - team portainer.TeamService - tunnelServer portainer.TunnelServerService - user portainer.UserService - version portainer.VersionService - webhook portainer.WebhookService + customTemplate portainer.CustomTemplateService + edgeGroup portainer.EdgeGroupService + edgeJob portainer.EdgeJobService + edgeStack portainer.EdgeStackService + endpoint portainer.EndpointService + endpointGroup portainer.EndpointGroupService + endpointRelation portainer.EndpointRelationService + helmUserRepository portainer.HelmUserRepositoryService + registry portainer.RegistryService + resourceControl portainer.ResourceControlService + apiKeyRepositoryService portainer.APIKeyRepository + role portainer.RoleService + sslSettings portainer.SSLSettingsService + settings portainer.SettingsService + stack portainer.StackService + tag portainer.TagService + teamMembership portainer.TeamMembershipService + team portainer.TeamService + tunnelServer portainer.TunnelServerService + user portainer.UserService + version portainer.VersionService + webhook portainer.WebhookService } func (d *datastore) BackupTo(io.Writer) error { return nil } @@ -52,16 +53,19 @@ func (d *datastore) HelmUserRepository() portainer.HelmUserRepositoryService { func (d *datastore) Registry() portainer.RegistryService { return d.registry } func (d *datastore) ResourceControl() portainer.ResourceControlService { return d.resourceControl } func (d *datastore) Role() portainer.RoleService { return d.role } -func (d *datastore) Settings() portainer.SettingsService { return d.settings } -func (d *datastore) SSLSettings() portainer.SSLSettingsService { return d.sslSettings } -func (d *datastore) Stack() portainer.StackService { return d.stack } -func (d *datastore) Tag() portainer.TagService { return d.tag } -func (d *datastore) TeamMembership() portainer.TeamMembershipService { return d.teamMembership } -func (d *datastore) Team() portainer.TeamService { return d.team } -func (d *datastore) TunnelServer() portainer.TunnelServerService { return d.tunnelServer } -func (d *datastore) User() portainer.UserService { return d.user } -func (d *datastore) Version() portainer.VersionService { return d.version } -func (d *datastore) Webhook() portainer.WebhookService { return d.webhook } +func (d *datastore) APIKeyRepository() portainer.APIKeyRepository { + return d.apiKeyRepositoryService +} +func (d *datastore) Settings() portainer.SettingsService { return d.settings } +func (d *datastore) SSLSettings() portainer.SSLSettingsService { return d.sslSettings } +func (d *datastore) Stack() portainer.StackService { return d.stack } +func (d *datastore) Tag() portainer.TagService { return d.tag } +func (d *datastore) TeamMembership() portainer.TeamMembershipService { return d.teamMembership } +func (d *datastore) Team() portainer.TeamService { return d.team } +func (d *datastore) TunnelServer() portainer.TunnelServerService { return d.tunnelServer } +func (d *datastore) User() portainer.UserService { return d.user } +func (d *datastore) Version() portainer.VersionService { return d.version } +func (d *datastore) Webhook() portainer.WebhookService { return d.webhook } type datastoreOption = func(d *datastore) diff --git a/api/portainer.go b/api/portainer.go index 3d2ba11ec..294f17fc1 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -696,6 +696,20 @@ type ( // RoleID represents a role identifier RoleID int + // APIKeyID represents an API key identifier + APIKeyID int + + // APIKey represents an API key + APIKey struct { + ID APIKeyID `json:"id" example:"1"` + UserID UserID `json:"userId" example:"1"` + Description string `json:"description" example:"portainer-api-key"` + Prefix string `json:"prefix"` // API key identifier (7 char prefix) + DateCreated int64 `json:"dateCreated"` // Unix timestamp (UTC) when the API key was created + LastUsed int64 `json:"lastUsed"` // Unix timestamp (UTC) when the API key was last used + Digest []byte `json:"digest,omitempty"` // Digest represents SHA256 hash of the raw API key + } + // Schedule represents a scheduled job. // It only contains a pointer to one of the JobRunner implementations // based on the JobType. @@ -1160,6 +1174,7 @@ type ( Registry() RegistryService ResourceControl() ResourceControlService Role() RoleService + APIKeyRepository() APIKeyRepository Settings() SettingsService SSLSettings() SSLSettingsService Stack() StackService @@ -1390,6 +1405,16 @@ type ( UpdateRole(ID RoleID, role *Role) error } + // APIKeyRepositoryService + APIKeyRepository interface { + CreateAPIKey(key *APIKey) error + GetAPIKey(keyID APIKeyID) (*APIKey, error) + UpdateAPIKey(key *APIKey) error + DeleteAPIKey(ID APIKeyID) error + GetAPIKeysByUserID(userID UserID) ([]APIKey, error) + GetAPIKeyByDigest(digest []byte) (*APIKey, error) + } + // SettingsService represents a service for managing application settings SettingsService interface { Settings() (*Settings, error) diff --git a/app/portainer/__module.js b/app/portainer/__module.js index 4b9d6b258..6df82a2d5 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -115,6 +115,16 @@ angular }, }; + const tokenCreation = { + name: 'portainer.account.new-access-token', + url: '/tokens/new', + views: { + 'content@': { + component: 'createUserAccessToken', + }, + }, + }; + var authentication = { name: 'portainer.auth', url: '/auth', @@ -429,6 +439,7 @@ angular $stateRegistryProvider.register(endpointRoot); $stateRegistryProvider.register(portainer); $stateRegistryProvider.register(account); + $stateRegistryProvider.register(tokenCreation); $stateRegistryProvider.register(authentication); $stateRegistryProvider.register(logout); $stateRegistryProvider.register(endpoints); diff --git a/app/portainer/components/Button/Button.tsx b/app/portainer/components/Button/Button.tsx index efd21a1f7..666a69c46 100644 --- a/app/portainer/components/Button/Button.tsx +++ b/app/portainer/components/Button/Button.tsx @@ -3,12 +3,14 @@ import clsx from 'clsx'; type Type = 'submit' | 'reset' | 'button'; type Color = 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'link'; -type Size = 'xsmall' | 'small' | 'large'; +type Size = 'xsmall' | 'small' | 'medium' | 'large'; export interface Props { type?: Type; color?: Color; size?: Size; disabled?: boolean; + title?: string; + className?: string; onClick: () => void; } @@ -17,7 +19,9 @@ export function Button({ color = 'primary', size = 'small', disabled = false, + className, onClick, + title, children, }: PropsWithChildren) { return ( @@ -25,8 +29,9 @@ export function Button({ /* eslint-disable-next-line react/button-has-type */ type={type} disabled={disabled} - className={clsx('btn', `btn-${color}`, sizeClass(size))} + className={clsx('btn', `btn-${color}`, sizeClass(size), className)} onClick={onClick} + title={title} > {children} @@ -37,6 +42,8 @@ function sizeClass(size?: Size) { switch (size) { case 'large': return 'btn-lg'; + case 'medium': + return 'btn-md'; case 'xsmall': return 'btn-xs'; default: diff --git a/app/portainer/components/Button/CopyButton/CopyButton.module.css b/app/portainer/components/Button/CopyButton/CopyButton.module.css new file mode 100644 index 000000000..4a607d364 --- /dev/null +++ b/app/portainer/components/Button/CopyButton/CopyButton.module.css @@ -0,0 +1,44 @@ +.fadeout { + animation: fadeOut 2.5s; + animation-fill-mode: forwards; +} + +.container { + display: flex; + align-items: baseline; +} + +.display-text { + opacity: 0; + margin-left: 7px; + color: #23ae89; +} + +@-webkit-keyframes fadeOut { + 0% { + opacity: 1; + } + 50% { + opacity: 0.8; + } + 99% { + opacity: 0.01; + } + 100% { + opacity: 0; + } +} +@keyframes fadeOut { + 0% { + opacity: 1; + } + 50% { + opacity: 0.8; + } + 99% { + opacity: 0.01; + } + 100% { + opacity: 0; + } +} diff --git a/app/portainer/components/Button/CopyButton/CopyButton.stories.tsx b/app/portainer/components/Button/CopyButton/CopyButton.stories.tsx new file mode 100644 index 000000000..3a05833e4 --- /dev/null +++ b/app/portainer/components/Button/CopyButton/CopyButton.stories.tsx @@ -0,0 +1,34 @@ +import { Meta, Story } from '@storybook/react'; +import { PropsWithChildren } from 'react'; + +import { CopyButton, Props } from './CopyButton'; + +export default { + component: CopyButton, + title: 'Components/Buttons/CopyButton', +} as Meta; + +function Template({ + copyText, + displayText, + children, +}: JSX.IntrinsicAttributes & PropsWithChildren) { + return ( + + {children} + + ); +} + +export const Primary: Story> = Template.bind({}); +Primary.args = { + children: 'Copy to clipboard', + copyText: 'this will be copied to clipboard', +}; + +export const NoCopyText: Story> = Template.bind({}); +NoCopyText.args = { + children: 'Copy to clipboard without copied text', + copyText: 'clipboard override', + displayText: '', +}; diff --git a/app/portainer/components/Button/CopyButton/CopyButton.test.tsx b/app/portainer/components/Button/CopyButton/CopyButton.test.tsx new file mode 100644 index 000000000..75c047aec --- /dev/null +++ b/app/portainer/components/Button/CopyButton/CopyButton.test.tsx @@ -0,0 +1,37 @@ +import { fireEvent, render } from '@testing-library/react'; + +import { CopyButton } from './CopyButton'; + +test('should display a CopyButton with children', async () => { + const children = 'test button children'; + const { findByText } = render( + {children} + ); + + const button = await findByText(children); + expect(button).toBeTruthy(); +}); + +test('CopyButton should copy text to clipboard', async () => { + // override navigator.clipboard.writeText (to test copy to clipboard functionality) + let clipboardText = ''; + const writeText = jest.fn((text) => { + clipboardText = text; + }); + Object.assign(navigator, { + clipboard: { writeText }, + }); + + const children = 'button'; + const copyText = 'text successfully copied to clipboard'; + const { findByText } = render( + {children} + ); + + const button = await findByText(children); + expect(button).toBeTruthy(); + + fireEvent.click(button); + expect(clipboardText).toBe(copyText); + expect(writeText).toHaveBeenCalled(); +}); diff --git a/app/portainer/components/Button/CopyButton/CopyButton.tsx b/app/portainer/components/Button/CopyButton/CopyButton.tsx new file mode 100644 index 000000000..f4d33051b --- /dev/null +++ b/app/portainer/components/Button/CopyButton/CopyButton.tsx @@ -0,0 +1,56 @@ +import { PropsWithChildren, useState, useEffect } from 'react'; +import clsx from 'clsx'; + +import { Button } from '../Button'; + +import styles from './CopyButton.module.css'; + +export interface Props { + copyText: string; + fadeDelay?: number; + displayText?: string; + className?: string; +} + +export function CopyButton({ + copyText, + fadeDelay = 1000, + displayText = 'copied', + className, + children, +}: PropsWithChildren) { + const [isFading, setIsFading] = useState(false); + + useEffect(() => { + const fadeoutTime = setTimeout(() => setIsFading(false), fadeDelay); + // clear timeout when component unmounts + return () => { + clearTimeout(fadeoutTime); + }; + }, [isFading, fadeDelay]); + + function onClick() { + // https://developer.mozilla.org/en-US/docs/Web/API/Clipboard + // https://caniuse.com/?search=clipboard + navigator.clipboard.writeText(copyText); + setIsFading(true); + } + + return ( +
+