1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-04 21:35:23 +02:00

feat(api-key/backend): introducing support for api-key based auth EE-978 (#6079)

* feat(access-token): Multi-auth middleware support EE-1891 (#5936)

* AnyAuth middleware initial implementation with tests

* using mux.MiddlewareFunc instead of custom definition

* removed redundant comments

* - ExtractBearerToken bouncer func made private
- changed helm token handling functionality to use jwt service to convert token to jwt string
- updated tests
- fixed helm list broken test due to missing token in request context

* rename mwCheckAuthentication -> mwCheckJWTAuthentication

* - introduce initial api-key auth support using X-API-KEY header
- added tests to validate x-api-key request header presence

* updated core mwAuthenticatedUser middleware to support multiple auth paradigms

* - simplified anyAuth middleware
- enforcing authmiddleware to implement verificationFunc interface
- created tests for middleware

* simplify bouncer

Co-authored-by: Dmitry Salakhov <to@dimasalakhov.com>

* feat(api-key): user-access-token generation endpoint EE-1889 EE-1888 EE-1895 (#6012)

* user-access-token generation endpoint

* fix comment

* - introduction of apikey service
- seperation of repository from service logic - called in handler

* fixed tests

* - fixed api key prefix
- added tests

* added another test for digest matching

* updated swagger spec for access token creation

* api key response returns raw key and struct - easing testability

* test for api key prefix length

* added another TODO to middleware

* - api-key prefix rune -> string (rune does not auto-encode when response sent back to client)
- digest -> pointer as we want to allow nil values and omit digest in responses (when nil)

* - updated apikey struct
- updated apikey service to support all common operations
- updated apikey repo
- integration of apikey service into bouncer
- added test for all apikey service functions
- boilerplate code for apikey service integration

* - user access token generation tests
- apiKeyLookup updated to support query params
- added api-key tests for query params
- added api-key tests for apiKeyLookup

* get and remove access token handlers

* get and remove access token handler tests

* - delete user deletes all associated api keys
- tests for this functionality

* removed redundant []byte cast

* automatic api-key eviction set within cache for 1 hour

* fixed bug with loop var using final value

* fixed service comment

* ignore bolt error responses

* case-insensitive query param check

* simplified query var assignment

* - added GetAPIKey func to get by unique id
- updated DeleteAPIKey func to not require user ID
- updated tests

* GenerateRandomKey helper func from github.com/gorilla/securecookie moved to codebase

* json response casing for api-keys fixed

* updating api-key will update the cache

* updated golang LRU cache

* using hashicorps golang-LRU cache for api keys

* simplified jwt check in create user access token

* fixed api-key update logic on cache miss

* Prefix generated api-keys with `ptr_` (#6067)

* prefix api-keys with 'ptr_'

* updated apikey description

* refactor

Co-authored-by: Dmitry Salakhov <to@dimasalakhov.com>

* helm list test refactor

* fixed user delete test

* reduce test nil pointer errors

* using correct http 201 created status code for token creation; updated tests

* fixed swagger doc user id path param for user access token based endpoints

* added api-key security openapi spec to existing jwt secured endpoints (#6091)

* fixed flaky test

* apikey datecreated and lastused attrs converted to unix timestamp

* feat(user): added access token datatable. (#6124)

* feat(user): added access token datatable.

* feat(tokens): only display lastUsed time when it is not the default date

* Update app/portainer/views/account/accountController.js

Co-authored-by: zees-dev <63374656+zees-dev@users.noreply.github.com>

* Update app/portainer/views/account/accountController.js

Co-authored-by: zees-dev <63374656+zees-dev@users.noreply.github.com>

* Update app/portainer/views/account/accountController.js

Co-authored-by: zees-dev <63374656+zees-dev@users.noreply.github.com>

* Update app/portainer/components/datatables/access-tokens-datatable/accessTokensDatatableController.js

Co-authored-by: zees-dev <63374656+zees-dev@users.noreply.github.com>

* Update app/portainer/services/api/userService.js

Co-authored-by: zees-dev <63374656+zees-dev@users.noreply.github.com>

* feat(improvements): proposed datatable improvements to speed up dev time (#6138)

* modal code update

* updated datatable filenames, updated controller to be default class export

* fix(access-token): code improvement.

Co-authored-by: zees-dev <63374656+zees-dev@users.noreply.github.com>

* feat(apikeys): create access token view initial implementation EE-1886 (#6129)

* CopyButton implementation

* Code component implementation

* ToolTip component migration to another folder

* TextTip component implementation - continued

* form Heading component

* Button component updated to be more dynamic

* copybutton - small size

* form control pass tip error

* texttip small text

* CreateAccessToken react feature initial implementation

* create user access token angularjs view implementation

* registration of CreateAccessToken component in AngularJS

* user token generation API request moved to angular service, method passed down instead

* consistent naming of access token operations; clustered similar code together

* any user can add access token

* create access token page routing

* moved code component to the correct location

* removed isadmin check as all functionality applicable to all users

* create access token angular view moved up a level

* fixed PR issues, updated PR

* addressed PR issues/improvements

* explicit hr for horizontal line

* fixed merge conflict storybook build breaking

* - apikey test
- cache test

* addressed testing issues:
- description validations
- remove token description link on table

* fix(api-keys): user role change evicts user keys in cache EE-2113 (#6168)

* user role change evicts user api keys in cache

* EvictUserKeyCache -> InvalidateUserKeyCache

* godoc for InvalidateUserKeyCache func

* additional test line

* disable add access token button after adding token to prevent spam

Co-authored-by: Dmitry Salakhov <to@dimasalakhov.com>
Co-authored-by: fhanportainer <79428273+fhanportainer@users.noreply.github.com>
This commit is contained in:
zees-dev 2021-11-30 15:31:16 +13:00 committed by GitHub
parent 120584909c
commit 69c17986d9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
197 changed files with 3137 additions and 91 deletions

View file

@ -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",

View file

@ -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

View file

@ -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})
}

View file

@ -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)
}
}
}

View file

@ -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)
}

View file

@ -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))
})
}

View file

@ -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
}

View file

@ -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")
}

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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)
}

View file

@ -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))
})
}

View file

@ -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)
}

View file

@ -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

View file

@ -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))
})
}