From 436da01bce8088d86febe88a4e3f78c3aa6774fc Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Mon, 20 Nov 2023 09:35:03 +0200 Subject: [PATCH] feat(auth): save jwt in cookie [EE-5864] (#10527) --- api/cmd/portainer/main.go | 2 +- api/dataservices/interface.go | 10 - api/http/csrf/csrf.go | 62 +++++++ api/http/handler/auth/authenticate.go | 6 +- api/http/handler/auth/handler.go | 2 +- api/http/handler/auth/logout.go | 5 +- .../customtemplate_git_fetch_test.go | 8 +- .../handler/endpoints/endpoint_list_test.go | 2 +- api/http/handler/helm/handler.go | 6 +- api/http/handler/helm/helm_delete_test.go | 3 +- api/http/handler/helm/helm_install_test.go | 3 +- api/http/handler/helm/helm_list_test.go | 3 +- api/http/handler/kubernetes/handler.go | 26 +-- api/http/handler/settings/handler.go | 2 +- api/http/handler/system/version_test.go | 6 +- api/http/handler/teams/team_list_test.go | 13 +- api/http/handler/users/handler.go | 4 + .../handler/users/user_create_access_token.go | 6 +- .../users/user_create_access_token_test.go | 12 +- .../users/user_get_access_tokens_test.go | 10 +- api/http/handler/users/user_inspect_me.go | 49 +++++ api/http/handler/users/user_list_test.go | 17 +- .../users/user_remove_access_token_test.go | 12 +- .../proxy/factory/azure/containergroup.go | 11 +- api/http/security/bouncer.go | 174 ++++++++++++++---- api/http/security/bouncer_test.go | 167 ++++++++++++++--- api/http/server.go | 9 +- api/internal/testhelpers/request_bouncer.go | 12 +- api/jwt/jwt.go | 20 +- api/kubernetes/cli/client.go | 8 +- api/portainer.go | 10 + app/app.js | 5 +- app/config.js | 17 +- .../console/containerConsoleController.js | 2 - app/index.js | 1 - app/portainer/__module.js | 35 +--- app/portainer/services/authentication.js | 55 +++--- app/portainer/services/axios.ts | 50 ++--- app/portainer/services/csrf.ts | 37 ++++ app/portainer/services/localStorage.js | 14 +- app/portainer/services/notifications.ts | 10 +- app/portainer/users/queries/queryKeys.ts | 1 + .../users/queries/useLoadCurrentUser.ts | 32 ++++ app/react/hooks/useUser.tsx | 15 +- .../applications/ConsoleView/ConsoleView.tsx | 4 - .../KubectlShell/KubectlShell.tsx | 10 +- app/vendors.js | 1 - go.mod | 4 + go.sum | 6 + package.json | 2 - yarn.lock | 10 - 51 files changed, 679 insertions(+), 312 deletions(-) create mode 100644 api/http/csrf/csrf.go create mode 100644 api/http/handler/users/user_inspect_me.go create mode 100644 app/portainer/services/csrf.ts create mode 100644 app/portainer/users/queries/useLoadCurrentUser.ts diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 76a7bc250..1b865581d 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -200,7 +200,7 @@ func initAPIKeyService(datastore dataservices.DataStore) apikey.APIKeyService { return apikey.NewAPIKeyService(datastore.APIKeyRepository(), datastore.User()) } -func initJWTService(userSessionTimeout string, dataStore dataservices.DataStore) (dataservices.JWTService, error) { +func initJWTService(userSessionTimeout string, dataStore dataservices.DataStore) (portainer.JWTService, error) { if userSessionTimeout == "" { userSessionTimeout = portainer.DefaultUserSessionTimeout } diff --git a/api/dataservices/interface.go b/api/dataservices/interface.go index 80b19bef1..2ca76dd81 100644 --- a/api/dataservices/interface.go +++ b/api/dataservices/interface.go @@ -2,7 +2,6 @@ package dataservices import ( "io" - "time" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/database/models" @@ -133,15 +132,6 @@ type ( HelmUserRepositoryByUserID(userID portainer.UserID) ([]portainer.HelmUserRepository, error) } - // JWTService represents a service for managing JWT tokens - JWTService interface { - GenerateToken(data *portainer.TokenData) (string, error) - GenerateTokenForOAuth(data *portainer.TokenData, expiryTime *time.Time) (string, error) - GenerateTokenForKubeconfig(data *portainer.TokenData) (string, error) - ParseAndVerifyToken(token string) (*portainer.TokenData, error) - SetUserSessionDuration(userSessionDuration time.Duration) - } - // RegistryService represents a service for managing registry data RegistryService interface { BaseCRUD[portainer.Registry, portainer.RegistryID] diff --git a/api/http/csrf/csrf.go b/api/http/csrf/csrf.go new file mode 100644 index 000000000..f1755625f --- /dev/null +++ b/api/http/csrf/csrf.go @@ -0,0 +1,62 @@ +package csrf + +import ( + "crypto/rand" + "fmt" + "net/http" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + gorillacsrf "github.com/gorilla/csrf" + "github.com/portainer/portainer/api/http/security" + "github.com/urfave/negroni" +) + +func WithProtect(handler http.Handler) (http.Handler, error) { + handler = withSendCSRFToken(handler) + + token := make([]byte, 32) + _, err := rand.Read(token) + if err != nil { + return nil, fmt.Errorf("failed to generate CSRF token: %w", err) + } + + handler = gorillacsrf.Protect([]byte(token), gorillacsrf.Path("/"))(handler) + + return withSkipCSRF(handler), nil +} + +func withSendCSRFToken(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + + sw := negroni.NewResponseWriter(w) + + sw.Before(func(sw negroni.ResponseWriter) { + statusCode := sw.Status() + if statusCode >= 200 && statusCode < 300 { + csrfToken := gorillacsrf.Token(r) + sw.Header().Set("X-CSRF-Token", csrfToken) + } + }) + + handler.ServeHTTP(sw, r) + + }) +} + +func withSkipCSRF(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + + skip, err := security.ShouldSkipCSRFCheck(r) + if err != nil { + httperror.WriteError(w, http.StatusForbidden, err.Error(), err) + return + } + + if skip { + r = gorillacsrf.UnsafeSkipCheck(r) + } + + handler.ServeHTTP(w, r) + }) +} diff --git a/api/http/handler/auth/authenticate.go b/api/http/handler/auth/authenticate.go index 90521afa4..0098e00b1 100644 --- a/api/http/handler/auth/authenticate.go +++ b/api/http/handler/auth/authenticate.go @@ -6,6 +6,7 @@ import ( portainer "github.com/portainer/portainer/api" httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" @@ -142,12 +143,15 @@ func (handler *Handler) writeToken(w http.ResponseWriter, user *portainer.User, } func (handler *Handler) persistAndWriteToken(w http.ResponseWriter, tokenData *portainer.TokenData) *httperror.HandlerError { - token, err := handler.JWTService.GenerateToken(tokenData) + token, expirationTime, err := handler.JWTService.GenerateToken(tokenData) if err != nil { return httperror.InternalServerError("Unable to generate JWT token", err) } + security.AddAuthCookie(w, token, expirationTime) + return response.JSON(w, &authenticateResponse{JWT: token}) + } func (handler *Handler) syncUserTeamsWithLDAPGroups(user *portainer.User, settings *portainer.LDAPSettings) error { diff --git a/api/http/handler/auth/handler.go b/api/http/handler/auth/handler.go index b57cf803e..78a1aa2fb 100644 --- a/api/http/handler/auth/handler.go +++ b/api/http/handler/auth/handler.go @@ -18,7 +18,7 @@ type Handler struct { *mux.Router DataStore dataservices.DataStore CryptoService portainer.CryptoService - JWTService dataservices.JWTService + JWTService portainer.JWTService LDAPService portainer.LDAPService OAuthService portainer.OAuthService ProxyManager *proxy.Manager diff --git a/api/http/handler/auth/logout.go b/api/http/handler/auth/logout.go index 9d44db1ae..f551cbcf1 100644 --- a/api/http/handler/auth/logout.go +++ b/api/http/handler/auth/logout.go @@ -3,6 +3,7 @@ package auth import ( "net/http" + "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/logoutcontext" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/response" @@ -18,12 +19,14 @@ import ( // @failure 500 "Server error" // @router /auth/logout [post] func (handler *Handler) logout(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - tokenData := handler.bouncer.JWTAuthLookup(r) + tokenData, _ := handler.bouncer.CookieAuthLookup(r) if tokenData != nil { handler.KubernetesTokenCacheManager.RemoveUserFromCache(tokenData.ID) logoutcontext.Cancel(tokenData.Token) } + security.RemoveAuthCookie(w) + return response.Empty(w) } diff --git a/api/http/handler/customtemplates/customtemplate_git_fetch_test.go b/api/http/handler/customtemplates/customtemplate_git_fetch_test.go index 1e645abd9..736897de6 100644 --- a/api/http/handler/customtemplates/customtemplate_git_fetch_test.go +++ b/api/http/handler/customtemplates/customtemplate_git_fetch_test.go @@ -2,7 +2,6 @@ package customtemplates import ( "bytes" - "fmt" "io" "io/fs" "net/http" @@ -18,6 +17,7 @@ import ( gittypes "github.com/portainer/portainer/api/git/types" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/internal/testhelpers" "github.com/portainer/portainer/api/jwt" "github.com/segmentio/encoding/json" @@ -76,7 +76,7 @@ func singleAPIRequest(h *Handler, jwt string, is *assert.Assertions, expect stri } req := httptest.NewRequest(http.MethodPut, "/custom_templates/1/git_fetch", bytes.NewBuffer([]byte("{}"))) - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt)) + testhelpers.AddTestSecurityCookie(req, jwt) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) @@ -132,8 +132,8 @@ func Test_customTemplateGitFetch(t *testing.T) { h := NewHandler(requestBouncer, store, fileService, gitService) // generate two standard users' tokens - jwt1, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user1.ID, Username: user1.Username, Role: user1.Role}) - jwt2, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user2.ID, Username: user2.Username, Role: user2.Role}) + jwt1, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user1.ID, Username: user1.Username, Role: user1.Role}) + jwt2, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user2.ID, Username: user2.Username, Role: user2.Role}) t.Run("can return the expected file content by a single call from one user", func(t *testing.T) { singleAPIRequest(h, jwt1, is, "abcdefg") diff --git a/api/http/handler/endpoints/endpoint_list_test.go b/api/http/handler/endpoints/endpoint_list_test.go index 6db7de3b1..4d091638c 100644 --- a/api/http/handler/endpoints/endpoint_list_test.go +++ b/api/http/handler/endpoints/endpoint_list_test.go @@ -211,7 +211,7 @@ func buildEndpointListRequest(query string) *http.Request { restrictedCtx := security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true}) req = req.WithContext(restrictedCtx) - req.Header.Add("Authorization", "Bearer dummytoken") + testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken") return req } diff --git a/api/http/handler/helm/handler.go b/api/http/handler/helm/handler.go index 86352c375..584ebfa8a 100644 --- a/api/http/handler/helm/handler.go +++ b/api/http/handler/helm/handler.go @@ -20,14 +20,14 @@ type Handler struct { *mux.Router requestBouncer security.BouncerService dataStore dataservices.DataStore - jwtService dataservices.JWTService + jwtService portainer.JWTService kubeClusterAccessService kubernetes.KubeClusterAccessService kubernetesDeployer portainer.KubernetesDeployer helmPackageManager libhelm.HelmPackageManager } // NewHandler creates a handler to manage endpoint group operations. -func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, jwtService dataservices.JWTService, kubernetesDeployer portainer.KubernetesDeployer, helmPackageManager libhelm.HelmPackageManager, kubeClusterAccessService kubernetes.KubeClusterAccessService) *Handler { +func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, jwtService portainer.JWTService, kubernetesDeployer portainer.KubernetesDeployer, helmPackageManager libhelm.HelmPackageManager, kubeClusterAccessService kubernetes.KubeClusterAccessService) *Handler { h := &Handler{ Router: mux.NewRouter(), requestBouncer: bouncer, @@ -93,7 +93,7 @@ func (handler *Handler) getHelmClusterAccess(r *http.Request) (*options.Kubernet return nil, httperror.InternalServerError("Unable to retrieve user authentication token", err) } - bearerToken, err := handler.jwtService.GenerateToken(tokenData) + bearerToken, _, err := handler.jwtService.GenerateToken(tokenData) if err != nil { return nil, httperror.Unauthorized("Unauthorized", err) } diff --git a/api/http/handler/helm/helm_delete_test.go b/api/http/handler/helm/helm_delete_test.go index bece6fbde..cb2aca2d3 100644 --- a/api/http/handler/helm/helm_delete_test.go +++ b/api/http/handler/helm/helm_delete_test.go @@ -16,6 +16,7 @@ import ( "github.com/portainer/portainer/pkg/libhelm/options" "github.com/stretchr/testify/assert" + "github.com/portainer/portainer/api/internal/testhelpers" helper "github.com/portainer/portainer/api/internal/testhelpers" ) @@ -48,7 +49,7 @@ func Test_helmDelete(t *testing.T) { req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/1/kubernetes/helm/%s", options.Name), nil) ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1}) req = req.WithContext(ctx) - req.Header.Add("Authorization", "Bearer dummytoken") + testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken") rr := httptest.NewRecorder() h.ServeHTTP(rr, req) diff --git a/api/http/handler/helm/helm_install_test.go b/api/http/handler/helm/helm_install_test.go index 5e3904550..869b80554 100644 --- a/api/http/handler/helm/helm_install_test.go +++ b/api/http/handler/helm/helm_install_test.go @@ -11,6 +11,7 @@ import ( "github.com/portainer/portainer/api/datastore" "github.com/portainer/portainer/api/exec/exectest" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/testhelpers" helper "github.com/portainer/portainer/api/internal/testhelpers" "github.com/portainer/portainer/api/jwt" "github.com/portainer/portainer/api/kubernetes" @@ -52,7 +53,7 @@ func Test_helmInstall(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/1/kubernetes/helm", bytes.NewBuffer(optdata)) ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1}) req = req.WithContext(ctx) - req.Header.Add("Authorization", "Bearer dummytoken") + testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken") rr := httptest.NewRecorder() h.ServeHTTP(rr, req) diff --git a/api/http/handler/helm/helm_list_test.go b/api/http/handler/helm/helm_list_test.go index 4de16589f..8ef51b324 100644 --- a/api/http/handler/helm/helm_list_test.go +++ b/api/http/handler/helm/helm_list_test.go @@ -10,6 +10,7 @@ import ( "github.com/portainer/portainer/api/datastore" "github.com/portainer/portainer/api/exec/exectest" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/testhelpers" helper "github.com/portainer/portainer/api/internal/testhelpers" "github.com/portainer/portainer/api/jwt" "github.com/portainer/portainer/api/kubernetes" @@ -48,7 +49,7 @@ func Test_helmList(t *testing.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") + testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken") rr := httptest.NewRecorder() h.ServeHTTP(rr, req) diff --git a/api/http/handler/kubernetes/handler.go b/api/http/handler/kubernetes/handler.go index 6025135ce..a0eeaebb5 100644 --- a/api/http/handler/kubernetes/handler.go +++ b/api/http/handler/kubernetes/handler.go @@ -26,12 +26,12 @@ type Handler struct { authorizationService *authorization.Service DataStore dataservices.DataStore KubernetesClientFactory *cli.ClientFactory - JwtService dataservices.JWTService + JwtService portainer.JWTService kubeClusterAccessService kubernetes.KubeClusterAccessService } // NewHandler creates a handler to process pre-proxied requests to external APIs. -func NewHandler(bouncer security.BouncerService, authorizationService *authorization.Service, dataStore dataservices.DataStore, jwtService dataservices.JWTService, kubeClusterAccessService kubernetes.KubeClusterAccessService, kubernetesClientFactory *cli.ClientFactory, kubernetesClient portainer.KubeClient) *Handler { +func NewHandler(bouncer security.BouncerService, authorizationService *authorization.Service, dataStore dataservices.DataStore, jwtService portainer.JWTService, kubeClusterAccessService kubernetes.KubeClusterAccessService, kubernetesClientFactory *cli.ClientFactory, kubernetesClient portainer.KubeClient) *Handler { h := &Handler{ Router: mux.NewRouter(), authorizationService: authorizationService, @@ -120,7 +120,12 @@ func (h *Handler) getProxyKubeClient(r *http.Request) (*cli.KubeClient, *httperr return nil, httperror.BadRequest("Invalid environment identifier route variable", err) } - cli, ok := h.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), r.Header.Get("Authorization")) + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return nil, httperror.Forbidden("Permission denied to access environment", err) + } + + cli, ok := h.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), tokenData.Username) if !ok { return nil, httperror.InternalServerError("Failed to lookup KubeClient", nil) } @@ -141,8 +146,13 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler { return } + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + httperror.WriteError(w, http.StatusForbidden, "Permission denied to access environment", err) + } + // Check if we have a kubeclient against this auth token already, otherwise generate a new one - _, ok := handler.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), r.Header.Get("Authorization")) + _, ok := handler.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), tokenData.Username) if ok { next.ServeHTTP(w, r) return @@ -164,12 +174,6 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler { return } - // Generate a proxied kubeconfig, then create a kubeclient using it. - tokenData, err := security.RetrieveTokenData(r) - if err != nil { - httperror.WriteError(w, http.StatusForbidden, "Permission denied to access environment", err) - return - } bearerToken, err := handler.JwtService.GenerateTokenForKubeconfig(tokenData) if err != nil { httperror.WriteError(w, http.StatusInternalServerError, "Unable to create JWT token", err) @@ -208,7 +212,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler { return } - handler.KubernetesClientFactory.SetProxyKubeClient(strconv.Itoa(int(endpoint.ID)), r.Header.Get("Authorization"), kubeCli) + handler.KubernetesClientFactory.SetProxyKubeClient(strconv.Itoa(int(endpoint.ID)), tokenData.Username, kubeCli) next.ServeHTTP(w, r) }) } diff --git a/api/http/handler/settings/handler.go b/api/http/handler/settings/handler.go index d966557a7..91da709b5 100644 --- a/api/http/handler/settings/handler.go +++ b/api/http/handler/settings/handler.go @@ -23,7 +23,7 @@ type Handler struct { *mux.Router DataStore dataservices.DataStore FileService portainer.FileService - JWTService dataservices.JWTService + JWTService portainer.JWTService LDAPService portainer.LDAPService SnapshotService portainer.SnapshotService demoService *demo.Service diff --git a/api/http/handler/system/version_test.go b/api/http/handler/system/version_test.go index f7628fc59..0eda6558d 100644 --- a/api/http/handler/system/version_test.go +++ b/api/http/handler/system/version_test.go @@ -1,7 +1,6 @@ package system import ( - "fmt" "io" "net/http" "net/http/httptest" @@ -13,6 +12,7 @@ import ( "github.com/portainer/portainer/api/datastore" "github.com/portainer/portainer/api/demo" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/testhelpers" "github.com/portainer/portainer/api/jwt" "github.com/segmentio/encoding/json" @@ -43,12 +43,12 @@ func Test_getSystemVersion(t *testing.T) { h := NewHandler(requestBouncer, &portainer.Status{}, &demo.Service{}, store, nil) // generate standard and admin user tokens - jwt, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role}) + jwt, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role}) t.Run("Display Edition", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/system/version", nil) - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt)) + testhelpers.AddTestSecurityCookie(req, jwt) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) diff --git a/api/http/handler/teams/team_list_test.go b/api/http/handler/teams/team_list_test.go index a94c966cb..8c0bdfc90 100644 --- a/api/http/handler/teams/team_list_test.go +++ b/api/http/handler/teams/team_list_test.go @@ -13,6 +13,7 @@ import ( "github.com/portainer/portainer/api/datastore" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/internal/testhelpers" "github.com/portainer/portainer/api/jwt" "github.com/segmentio/encoding/json" @@ -39,7 +40,7 @@ func Test_teamList(t *testing.T) { h.DataStore = store // generate admin user tokens - adminJWT, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role}) + adminJWT, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role}) // Case 1: the team is given the endpoint access directly // create teams @@ -77,11 +78,11 @@ func Test_teamList(t *testing.T) { err = store.Endpoint().Create(endpointWithTeamAccessPolicy) is.NoError(err, "error creating endpoint") - jwt, _ := jwtService.GenerateToken(&portainer.TokenData{ID: userWithEndpointAccessByTeam.ID, Username: userWithEndpointAccessByTeam.Username, Role: userWithEndpointAccessByTeam.Role}) + jwt, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: userWithEndpointAccessByTeam.ID, Username: userWithEndpointAccessByTeam.Username, Role: userWithEndpointAccessByTeam.Role}) t.Run("admin user can successfully list all teams", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/teams", nil) - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT)) + testhelpers.AddTestSecurityCookie(req, adminJWT) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) @@ -102,7 +103,7 @@ func Test_teamList(t *testing.T) { params := url.Values{} params.Add("environmentId", fmt.Sprintf("%d", endpointWithTeamAccessPolicy.ID)) req := httptest.NewRequest(http.MethodGet, "/teams?"+params.Encode(), nil) - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT)) + testhelpers.AddTestSecurityCookie(req, adminJWT) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) @@ -124,7 +125,7 @@ func Test_teamList(t *testing.T) { t.Run("standard user only can list team where he belongs to", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/teams", nil) - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt)) + testhelpers.AddTestSecurityCookie(req, jwt) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) @@ -168,7 +169,7 @@ func Test_teamList(t *testing.T) { params := url.Values{} params.Add("environmentId", fmt.Sprintf("%d", endpointUnderGroupWithTeam.ID)) req := httptest.NewRequest(http.MethodGet, "/teams?"+params.Encode(), nil) - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT)) + testhelpers.AddTestSecurityCookie(req, adminJWT) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) diff --git a/api/http/handler/users/handler.go b/api/http/handler/users/handler.go index 7c1364736..41e1b0f24 100644 --- a/api/http/handler/users/handler.go +++ b/api/http/handler/users/handler.go @@ -67,6 +67,9 @@ func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimit adminRouter.Handle("/users", httperror.LoggerHandler(h.userCreate)).Methods(http.MethodPost) restrictedRouter.Handle("/users", httperror.LoggerHandler(h.userList)).Methods(http.MethodGet) + + authenticatedRouter.Handle("/users/me", httperror.LoggerHandler(h.userInspectMe)).Methods(http.MethodGet) + restrictedRouter.Handle("/users/me", httperror.LoggerHandler(h.userInspectMe)).Methods(http.MethodGet) restrictedRouter.Handle("/users/{id}", httperror.LoggerHandler(h.userInspect)).Methods(http.MethodGet) authenticatedRouter.Handle("/users/{id}", httperror.LoggerHandler(h.userUpdate)).Methods(http.MethodPut) adminRouter.Handle("/users/{id}", httperror.LoggerHandler(h.userDelete)).Methods(http.MethodDelete) @@ -75,6 +78,7 @@ func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimit restrictedRouter.Handle("/users/{id}/tokens/{keyID}", httperror.LoggerHandler(h.userRemoveAccessToken)).Methods(http.MethodDelete) restrictedRouter.Handle("/users/{id}/memberships", httperror.LoggerHandler(h.userMemberships)).Methods(http.MethodGet) authenticatedRouter.Handle("/users/{id}/passwd", rateLimiter.LimitAccess(httperror.LoggerHandler(h.userUpdatePassword))).Methods(http.MethodPut) + publicRouter.Handle("/users/admin/check", httperror.LoggerHandler(h.adminCheck)).Methods(http.MethodGet) publicRouter.Handle("/users/admin/init", httperror.LoggerHandler(h.adminInit)).Methods(http.MethodPost) diff --git a/api/http/handler/users/user_create_access_token.go b/api/http/handler/users/user_create_access_token.go index cee657042..546afba62 100644 --- a/api/http/handler/users/user_create_access_token.go +++ b/api/http/handler/users/user_create_access_token.go @@ -55,9 +55,9 @@ type accessTokenResponse struct { // @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.Unauthorized("Auth not supported", errors.New("JWT Authentication required")) + // specifically require Cookie auth for this endpoint since API-Key based auth is not supported + if jwt, _ := handler.bouncer.CookieAuthLookup(r); jwt == nil { + return httperror.Unauthorized("Auth not supported", errors.New("Cookie Authentication required")) } var payload userAccessTokenCreatePayload diff --git a/api/http/handler/users/user_create_access_token_test.go b/api/http/handler/users/user_create_access_token_test.go index 2c4a54489..170965786 100644 --- a/api/http/handler/users/user_create_access_token_test.go +++ b/api/http/handler/users/user_create_access_token_test.go @@ -2,7 +2,6 @@ package users import ( "bytes" - "fmt" "io" "net/http" "net/http/httptest" @@ -13,6 +12,7 @@ import ( "github.com/portainer/portainer/api/apikey" "github.com/portainer/portainer/api/datastore" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/testhelpers" "github.com/portainer/portainer/api/jwt" "github.com/segmentio/encoding/json" @@ -45,8 +45,8 @@ func Test_userCreateAccessToken(t *testing.T) { 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}) + 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"} @@ -54,7 +54,7 @@ func Test_userCreateAccessToken(t *testing.T) { is.NoError(err) req := httptest.NewRequest(http.MethodPost, "/users/2/tokens", bytes.NewBuffer(payload)) - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt)) + testhelpers.AddTestSecurityCookie(req, jwt) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) @@ -77,7 +77,7 @@ func Test_userCreateAccessToken(t *testing.T) { is.NoError(err) req := httptest.NewRequest(http.MethodPost, "/users/2/tokens", bytes.NewBuffer(payload)) - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT)) + testhelpers.AddTestSecurityCookie(req, adminJWT) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) @@ -106,7 +106,7 @@ func Test_userCreateAccessToken(t *testing.T) { body, err := io.ReadAll(rr.Body) is.NoError(err, "ReadAll should not return error") - is.Equal(`{"message":"Auth not supported","details":"JWT Authentication required"}`, string(body)) + is.Equal(`{"message":"Auth not supported","details":"Cookie Authentication required"}`, string(body)) }) } diff --git a/api/http/handler/users/user_get_access_tokens_test.go b/api/http/handler/users/user_get_access_tokens_test.go index 849dc2f11..91c4ce0a8 100644 --- a/api/http/handler/users/user_get_access_tokens_test.go +++ b/api/http/handler/users/user_get_access_tokens_test.go @@ -1,7 +1,6 @@ package users import ( - "fmt" "io" "net/http" "net/http/httptest" @@ -12,6 +11,7 @@ import ( "github.com/portainer/portainer/api/apikey" "github.com/portainer/portainer/api/datastore" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/testhelpers" "github.com/portainer/portainer/api/jwt" "github.com/segmentio/encoding/json" @@ -44,15 +44,15 @@ func Test_userGetAccessTokens(t *testing.T) { 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}) + 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)) + testhelpers.AddTestSecurityCookie(req, jwt) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) @@ -81,7 +81,7 @@ func Test_userGetAccessTokens(t *testing.T) { is.NoError(err) req := httptest.NewRequest(http.MethodGet, "/users/2/tokens", nil) - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT)) + testhelpers.AddTestSecurityCookie(req, adminJWT) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) diff --git a/api/http/handler/users/user_inspect_me.go b/api/http/handler/users/user_inspect_me.go new file mode 100644 index 000000000..47f5fd96f --- /dev/null +++ b/api/http/handler/users/user_inspect_me.go @@ -0,0 +1,49 @@ +package users + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type CurrentUserInspectResponse struct { + *portainer.User + ForceChangePassword bool `json:"forceChangePassword"` +} + +// @id CurrentUserInspect +// @summary Inspect the current user user +// @description Retrieve details about the current user. +// @description User passwords are filtered out, and should never be accessible. +// @description **Access policy**: authenticated +// @tags users +// @security ApiKeyAuth +// @security jwt +// @produce json +// @success 200 {object} portainer.User "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 "User not found" +// @failure 500 "Server error" +// @router /users/me [get] +func (handler *Handler) userInspectMe(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + user, err := handler.DataStore.User().Read(securityContext.UserID) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a user with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find a user with the specified identifier inside the database", err) + } + + forceChangePassword := !handler.passwordStrengthChecker.Check(user.Password) + + hideFields(user) + return response.JSON(w, &CurrentUserInspectResponse{User: user, ForceChangePassword: forceChangePassword}) +} diff --git a/api/http/handler/users/user_list_test.go b/api/http/handler/users/user_list_test.go index 41c0a0e09..c7b0cf0ef 100644 --- a/api/http/handler/users/user_list_test.go +++ b/api/http/handler/users/user_list_test.go @@ -15,6 +15,7 @@ import ( "github.com/portainer/portainer/api/demo" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/internal/testhelpers" "github.com/portainer/portainer/api/jwt" "github.com/segmentio/encoding/json" @@ -43,7 +44,7 @@ func Test_userList(t *testing.T) { h.DataStore = store // generate admin user tokens - adminJWT, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role}) + adminJWT, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role}) // Case 1: the user is given the endpoint access directly userWithEndpointAccess := &portainer.User{ID: 2, Username: "standard-user-with-endpoint-access", Role: portainer.StandardUserRole, PortainerAuthorizations: authorization.DefaultPortainerAuthorizations()} @@ -67,11 +68,11 @@ func Test_userList(t *testing.T) { err = store.Endpoint().Create(endpointWithUserAccessPolicy) is.NoError(err, "error creating endpoint") - jwt, _ := jwtService.GenerateToken(&portainer.TokenData{ID: userWithEndpointAccess.ID, Username: userWithEndpointAccess.Username, Role: userWithEndpointAccess.Role}) + jwt, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: userWithEndpointAccess.ID, Username: userWithEndpointAccess.Username, Role: userWithEndpointAccess.Role}) t.Run("admin user can successfully list all users", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/users", nil) - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT)) + testhelpers.AddTestSecurityCookie(req, adminJWT) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) @@ -92,7 +93,7 @@ func Test_userList(t *testing.T) { params := url.Values{} params.Add("environmentId", fmt.Sprintf("%d", endpointWithUserAccessPolicy.ID)) req := httptest.NewRequest(http.MethodGet, "/users?"+params.Encode(), nil) - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT)) + testhelpers.AddTestSecurityCookie(req, adminJWT) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) @@ -114,7 +115,7 @@ func Test_userList(t *testing.T) { t.Run("standard user cannot list users", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/users", nil) - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt)) + testhelpers.AddTestSecurityCookie(req, jwt) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) @@ -146,7 +147,7 @@ func Test_userList(t *testing.T) { params := url.Values{} params.Add("environmentId", fmt.Sprintf("%d", endpointUnderGroupWithUser.ID)) req := httptest.NewRequest(http.MethodGet, "/users?"+params.Encode(), nil) - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT)) + testhelpers.AddTestSecurityCookie(req, adminJWT) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) @@ -198,7 +199,7 @@ func Test_userList(t *testing.T) { params := url.Values{} params.Add("environmentId", fmt.Sprintf("%d", endpointUnderGroupWithTeam.ID)) req := httptest.NewRequest(http.MethodGet, "/users?"+params.Encode(), nil) - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT)) + testhelpers.AddTestSecurityCookie(req, adminJWT) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) @@ -249,7 +250,7 @@ func Test_userList(t *testing.T) { params := url.Values{} params.Add("environmentId", fmt.Sprintf("%d", endpointWithTeamAccessPolicy.ID)) req := httptest.NewRequest(http.MethodGet, "/users?"+params.Encode(), nil) - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT)) + testhelpers.AddTestSecurityCookie(req, adminJWT) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) diff --git a/api/http/handler/users/user_remove_access_token_test.go b/api/http/handler/users/user_remove_access_token_test.go index bc41a0c91..26b6648a5 100644 --- a/api/http/handler/users/user_remove_access_token_test.go +++ b/api/http/handler/users/user_remove_access_token_test.go @@ -11,6 +11,7 @@ import ( "github.com/portainer/portainer/api/apikey" "github.com/portainer/portainer/api/datastore" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/testhelpers" "github.com/portainer/portainer/api/jwt" "github.com/stretchr/testify/assert" ) @@ -41,15 +42,16 @@ func Test_userRemoveAccessToken(t *testing.T) { 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}) + 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) { + is := assert.New(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)) + testhelpers.AddTestSecurityCookie(req, jwt) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) @@ -63,11 +65,12 @@ func Test_userRemoveAccessToken(t *testing.T) { }) t.Run("admin can delete a standard user API Key", func(t *testing.T) { + is := assert.New(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)) + testhelpers.AddTestSecurityCookie(req, adminJWT) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) @@ -81,6 +84,7 @@ func Test_userRemoveAccessToken(t *testing.T) { }) t.Run("user can delete API Key using api-key auth", func(t *testing.T) { + is := assert.New(t) rawAPIKey, apiKey, err := apiKeyService.GenerateApiKey(*user, "test-api-key-auth-deletion") is.NoError(err) diff --git a/api/http/proxy/factory/azure/containergroup.go b/api/http/proxy/factory/azure/containergroup.go index 1f8ca581c..36a25b0f4 100644 --- a/api/http/proxy/factory/azure/containergroup.go +++ b/api/http/proxy/factory/azure/containergroup.go @@ -2,10 +2,13 @@ package azure import ( "errors" + "fmt" "net/http" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/proxy/factory/utils" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" ) // proxy for /subscriptions/*/resourceGroups/*/providers/Microsoft.ContainerInstance/containerGroups/* @@ -23,6 +26,12 @@ func (transport *Transport) proxyContainerGroupRequest(request *http.Request) (* } func (transport *Transport) proxyContainerGroupPutRequest(request *http.Request) (*http.Response, error) { + + tokenData, err := security.RetrieveTokenData(request) + if err != nil { + return nil, httperror.Forbidden("Permission denied to access environment", err) + } + //add a lock before processing existence check transport.mutex.Lock() defer transport.mutex.Unlock() @@ -32,7 +41,7 @@ func (transport *Transport) proxyContainerGroupPutRequest(request *http.Request) Method: http.MethodGet, URL: request.URL, Header: http.Header{ - "Authorization": []string{request.Header.Get("Authorization")}, + "Authorization": []string{fmt.Sprintf("Bearer %s", tokenData.Token)}, }, } diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go index 36c8eeada..c7dd164ff 100644 --- a/api/http/security/bouncer.go +++ b/api/http/security/bouncer.go @@ -26,13 +26,13 @@ type ( AuthorizedEndpointOperation(*http.Request, *portainer.Endpoint) error AuthorizedEdgeEndpointOperation(*http.Request, *portainer.Endpoint) error TrustedEdgeEnvironmentAccess(dataservices.DataStoreTx, *portainer.Endpoint) error - JWTAuthLookup(*http.Request) *portainer.TokenData + CookieAuthLookup(*http.Request) (*portainer.TokenData, error) } // RequestBouncer represents an entity that manages API request accesses RequestBouncer struct { dataStore dataservices.DataStore - jwtService dataservices.JWTService + jwtService portainer.JWTService apiKeyService apikey.APIKeyService } @@ -46,13 +46,14 @@ type ( } // tokenLookup looks up a token in the request - tokenLookup func(*http.Request) *portainer.TokenData + tokenLookup func(*http.Request) (*portainer.TokenData, error) ) const apiKeyHeader = "X-API-KEY" +const jwtTokenHeader = "Authorization" // NewRequestBouncer initializes a new RequestBouncer -func NewRequestBouncer(dataStore dataservices.DataStore, jwtService dataservices.JWTService, apiKeyService apikey.APIKeyService) *RequestBouncer { +func NewRequestBouncer(dataStore dataservices.DataStore, jwtService portainer.JWTService, apiKeyService apikey.APIKeyService) *RequestBouncer { return &RequestBouncer{ dataStore: dataStore, jwtService: jwtService, @@ -188,8 +189,9 @@ func (bouncer *RequestBouncer) TrustedEdgeEnvironmentAccess(tx dataservices.Data // - authenticating the request with a valid token func (bouncer *RequestBouncer) mwAuthenticatedUser(h http.Handler) http.Handler { h = bouncer.mwAuthenticateFirst([]tokenLookup{ - bouncer.JWTAuthLookup, bouncer.apiKeyLookup, + bouncer.CookieAuthLookup, + bouncer.JWTAuthLookup, }, h) h = mwSecureHeaders(h) return h @@ -276,24 +278,26 @@ func (bouncer *RequestBouncer) mwAuthenticateFirst(tokenLookups []tokenLookup, n var token *portainer.TokenData for _, lookup := range tokenLookups { - token = lookup(r) + resultToken, err := lookup(r) + if err != nil { + httperror.WriteError(w, http.StatusUnauthorized, "Invalid API key", httperrors.ErrUnauthorized) + return + } - if token != nil { + if resultToken != nil { + token = resultToken break } } if token == nil { - httperror.WriteError(w, http.StatusUnauthorized, "A valid authorisation token is missing", httperrors.ErrUnauthorized) + httperror.WriteError(w, http.StatusUnauthorized, "A valid authorization token is missing", httperrors.ErrUnauthorized) return } - _, err := bouncer.dataStore.User().Read(token.ID) - if err != nil && bouncer.dataStore.IsErrObjectNotFound(err) { - 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) + user, _ := bouncer.dataStore.User().Read(token.ID) + if user == nil { + httperror.WriteError(w, http.StatusUnauthorized, "An authorization token is invalid", httperrors.ErrUnauthorized) return } @@ -303,21 +307,39 @@ func (bouncer *RequestBouncer) mwAuthenticateFirst(tokenLookups []tokenLookup, n } // JWTAuthLookup looks up a valid bearer in the request. -func (bouncer *RequestBouncer) JWTAuthLookup(r *http.Request) *portainer.TokenData { +func (bouncer *RequestBouncer) CookieAuthLookup(r *http.Request) (*portainer.TokenData, error) { // get token from the Authorization header or query parameter - token, err := extractBearerToken(r) + token, err := extractKeyFromCookie(r) if err != nil { - return nil + return nil, nil } tokenData, err := bouncer.jwtService.ParseAndVerifyToken(token) if err != nil { - return nil + return nil, ErrInvalidKey } - return tokenData + return tokenData, nil } +// JWTAuthLookup looks up a valid bearer in the request. +func (bouncer *RequestBouncer) JWTAuthLookup(r *http.Request) (*portainer.TokenData, error) { + // get token from the Authorization header or query parameter + token, ok := extractBearerToken(r) + if !ok { + return nil, nil + } + + tokenData, err := bouncer.jwtService.ParseAndVerifyToken(token) + if err != nil { + return nil, ErrInvalidKey + } + + return tokenData, nil +} + +var ErrInvalidKey = errors.New("Invalid API key") + // apiKeyLookup looks up an verifies an api-key by: // - computing the digest of the raw api-key // - verifying it exists in cache/database @@ -325,17 +347,17 @@ func (bouncer *RequestBouncer) JWTAuthLookup(r *http.Request) *portainer.TokenDa // 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 { +func (bouncer *RequestBouncer) apiKeyLookup(r *http.Request) (*portainer.TokenData, error) { rawAPIKey, ok := extractAPIKey(r) if !ok { - return nil + return nil, nil } digest := bouncer.apiKeyService.HashRaw(rawAPIKey) user, apiKey, err := bouncer.apiKeyService.GetDigestUserAndKey(digest) if err != nil { - return nil + return nil, ErrInvalidKey } tokenData := &portainer.TokenData{ @@ -343,8 +365,8 @@ func (bouncer *RequestBouncer) apiKeyLookup(r *http.Request) *portainer.TokenDat Username: user.Username, Role: user.Role, } - if _, err := bouncer.jwtService.GenerateToken(tokenData); err != nil { - return nil + if _, _, err := bouncer.jwtService.GenerateToken(tokenData); err != nil { + return nil, ErrInvalidKey } if now := time.Now().UTC().Unix(); now-apiKey.LastUsed > 60 { // [seconds] @@ -353,32 +375,74 @@ func (bouncer *RequestBouncer) apiKeyLookup(r *http.Request) *portainer.TokenDat bouncer.apiKeyService.UpdateAPIKey(&apiKey) } - return tokenData + return tokenData, nil } // 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. +func extractBearerToken(r *http.Request) (string, bool) { + // Token might be set via the "token" query parameter. // For example, in websocket requests - token := r.URL.Query().Get("token") + // For these cases, hide the token from the query + query := r.URL.Query() + token := query.Get("token") + if token != "" { + query.Del("token") + r.URL.RawQuery = query.Encode() + return token, true + } - tokens, ok := r.Header["Authorization"] - if ok && len(tokens) >= 1 { - token = tokens[0] - token = strings.TrimPrefix(token, "Bearer ") + tokens, ok := r.Header[jwtTokenHeader] + if !ok || len(tokens) == 0 { + return "", false } - if token == "" { - return "", httperrors.ErrUnauthorized + + token = tokens[0] + token = strings.TrimPrefix(token, "Bearer ") + + return token, true +} + +// AddAuthCookie adds the jwt token to the response cookie. +func AddAuthCookie(w http.ResponseWriter, token string, expirationTime time.Time) { + http.SetCookie(w, &http.Cookie{ + Name: portainer.AuthCookieKey, + Value: token, + Path: "/", + Expires: expirationTime, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + }) +} + +// RemoveAuthCookie removes the jwt token from the response cookie. +func RemoveAuthCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: portainer.AuthCookieKey, + Value: "", + Path: "/", + Expires: time.Unix(0, 0), + HttpOnly: true, + MaxAge: -1, + SameSite: http.SameSiteStrictMode, + }) +} + +// extractKeyFromCookie extracts the jwt token from the cookie. +func extractKeyFromCookie(r *http.Request) (string, error) { + cookie, err := r.Cookie(portainer.AuthCookieKey) + if err != nil { + return "", err } - return token, nil + + return cookie.Value, nil } // extractAPIKey extracts the api key from the api key request header or query params. -func extractAPIKey(r *http.Request) (apikey string, ok bool) { +func extractAPIKey(r *http.Request) (string, bool) { // extract the API key from the request header - apikey = r.Header.Get(apiKeyHeader) - if apikey != "" { - return apikey, true + apiKey := r.Header.Get(apiKeyHeader) + if apiKey != "" { + return apiKey, true } // extract the API key from query params. @@ -448,3 +512,35 @@ func (bouncer *RequestBouncer) EdgeComputeOperation(next http.Handler) http.Hand next.ServeHTTP(w, r) }) } + +// ShouldSkipCSRFCheck checks if the CSRF check should be skipped +// +// It returns true if the request has no cookie token and has either (but not both): +// - an api key header +// - an auth header +// if it has both headers, an error is returned +// +// we allow CSRF check to be skipped for the following reasons: +// - public routes +// - kubectl - a bearer token is needed, and no csrf token can be sent +// - api token +func ShouldSkipCSRFCheck(r *http.Request) (bool, error) { + cookie, _ := r.Cookie(portainer.AuthCookieKey) + hasCookie := cookie != nil && cookie.Value != "" + + if hasCookie { + return false, nil + } + + apiKey := r.Header.Get(apiKeyHeader) + hasApiKey := apiKey != "" + + authHeader := r.Header.Get(jwtTokenHeader) + hasAuthHeader := authHeader != "" + + if hasApiKey && hasAuthHeader { + return false, errors.New("api key and auth header are not allowed at the same time") + } + + return true, nil +} diff --git a/api/http/security/bouncer_test.go b/api/http/security/bouncer_test.go index 4b93d23f6..20220e588 100644 --- a/api/http/security/bouncer_test.go +++ b/api/http/security/bouncer_test.go @@ -10,7 +10,7 @@ import ( "github.com/portainer/portainer/api/apikey" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/datastore" - httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/internal/testhelpers" "github.com/portainer/portainer/api/jwt" "github.com/stretchr/testify/assert" @@ -21,21 +21,24 @@ var testHandler200 = http.HandlerFunc(func(w http.ResponseWriter, r *http.Reques w.WriteHeader(http.StatusOK) }) -func tokenLookupSucceed(dataStore dataservices.DataStore, jwtService dataservices.JWTService) tokenLookup { - return func(r *http.Request) *portainer.TokenData { +func tokenLookupSucceed(dataStore dataservices.DataStore, jwtService portainer.JWTService) tokenLookup { + return func(r *http.Request) (*portainer.TokenData, error) { uid := portainer.UserID(1) dataStore.User().Create(&portainer.User{ID: uid}) jwtService.GenerateToken(&portainer.TokenData{ID: uid}) - return &portainer.TokenData{ID: 1} + return &portainer.TokenData{ID: 1}, nil } } -func tokenLookupFail(r *http.Request) *portainer.TokenData { - return nil +func tokenLookupFail(r *http.Request) (*portainer.TokenData, error) { + return nil, ErrInvalidKey +} + +func tokenLookupEmpty(r *http.Request) (*portainer.TokenData, error) { + return nil, nil } func Test_mwAuthenticateFirst(t *testing.T) { - is := assert.New(t) _, store := datastore.MustNewTestStore(t, true, true) @@ -79,17 +82,28 @@ func Test_mwAuthenticateFirst(t *testing.T) { wantStatusCode: http.StatusOK, }, { - name: "mwAuthenticateFirst succeeds if last middleware successfully handles request", + name: "mwAuthenticateFirst fails if first middleware fails", verificationMiddlwares: []tokenLookup{ tokenLookupFail, tokenLookupSucceed(store, jwtService), }, - wantStatusCode: http.StatusOK, + wantStatusCode: http.StatusUnauthorized, + }, + { + name: "mwAuthenticateFirst fails if first middleware has no token, but second middleware fails", + verificationMiddlwares: []tokenLookup{ + tokenLookupEmpty, + tokenLookupFail, + + tokenLookupSucceed(store, jwtService), + }, + wantStatusCode: http.StatusUnauthorized, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + is := assert.New(t) req := httptest.NewRequest(http.MethodGet, "/", nil) rr := httptest.NewRecorder() @@ -101,9 +115,46 @@ func Test_mwAuthenticateFirst(t *testing.T) { } } -func Test_extractBearerToken(t *testing.T) { +func Test_extractKeyFromCookie(t *testing.T) { is := assert.New(t) + tt := []struct { + name string + token string + succeeds bool + }{ + { + name: "missing cookie", + token: "", + succeeds: false, + }, + + { + name: "valid cookie", + token: "abc", + succeeds: true, + }, + } + + for _, test := range tt { + req := httptest.NewRequest(http.MethodGet, "/", nil) + if test.token != "" { + testhelpers.AddTestSecurityCookie(req, test.token) + } + + apiKey, err := extractKeyFromCookie(req) + is.Equal(test.token, apiKey) + if !test.succeeds { + is.Error(err, "Should return error") + is.ErrorIs(err, http.ErrNoCookie) + } else { + is.NoError(err) + } + } +} + +func Test_extractBearerToken(t *testing.T) { + tt := []struct { name string requestHeader string @@ -142,16 +193,14 @@ func Test_extractBearerToken(t *testing.T) { } 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) - } + t.Run(test.name, func(t *testing.T) { + is := assert.New(t) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set(test.requestHeader, test.requestHeaderValue) + apiKey, ok := extractBearerToken(req) + is.Equal(test.wantToken, apiKey) + is.Equal(test.succeeds, ok) + }) } } @@ -274,16 +323,17 @@ func Test_apiKeyLookup(t *testing.T) { 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) + // testhelpers.AddTestSecurityCookie(req, 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) + token, err := bouncer.apiKeyLookup(req) is.Nil(token) + is.Error(err) }) t.Run("valid x-api-key header succeeds api-key lookup", func(t *testing.T) { @@ -293,7 +343,7 @@ func Test_apiKeyLookup(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Add("x-api-key", rawAPIKey) - token := bouncer.apiKeyLookup(req) + token, err := bouncer.apiKeyLookup(req) expectedToken := &portainer.TokenData{ID: user.ID, Username: user.Username, Role: portainer.StandardUserRole} is.Equal(expectedToken, token) @@ -307,7 +357,7 @@ func Test_apiKeyLookup(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Add("x-api-key", rawAPIKey) - token := bouncer.apiKeyLookup(req) + token, err := bouncer.apiKeyLookup(req) expectedToken := &portainer.TokenData{ID: user.ID, Username: user.Username, Role: portainer.StandardUserRole} is.Equal(expectedToken, token) @@ -321,7 +371,7 @@ func Test_apiKeyLookup(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Add("x-api-key", rawAPIKey) - token := bouncer.apiKeyLookup(req) + token, err := bouncer.apiKeyLookup(req) expectedToken := &portainer.TokenData{ID: user.ID, Username: user.Username, Role: portainer.StandardUserRole} is.Equal(expectedToken, token) @@ -332,3 +382,68 @@ func Test_apiKeyLookup(t *testing.T) { is.True(apiKeyUpdated.LastUsed > apiKey.LastUsed) }) } + +func Test_ShouldSkipCSRFCheck(t *testing.T) { + + tt := []struct { + name string + cookieValue string + apiKey string + authHeader string + expectedResult bool + expectedError bool + }{ + { + name: "Should return false when cookie is present", + cookieValue: "test-cookie", + }, + { + name: "Should return true when cookie is not present", + cookieValue: "", + expectedResult: true, + }, + { + name: "Should return true when api key is present", + cookieValue: "", + apiKey: "test-api-key", + expectedResult: true, + }, + { + name: "Should return true when auth header is present", + cookieValue: "", + authHeader: "test-auth-header", + expectedResult: true, + }, + { + name: "Should return false and error when both api key and auth header are present", + cookieValue: "", + apiKey: "test-api-key", + authHeader: "test-auth-header", + expectedError: true, + }, + } + + for _, test := range tt { + t.Run(test.name, func(t *testing.T) { + is := assert.New(t) + req := httptest.NewRequest(http.MethodGet, "/", nil) + if test.cookieValue != "" { + req.AddCookie(&http.Cookie{Name: portainer.AuthCookieKey, Value: test.cookieValue}) + } + if test.apiKey != "" { + req.Header.Set(apiKeyHeader, test.apiKey) + } + if test.authHeader != "" { + req.Header.Set(jwtTokenHeader, test.authHeader) + } + + result, err := ShouldSkipCSRFCheck(req) + is.Equal(test.expectedResult, result) + if test.expectedError { + is.Error(err) + } else { + is.NoError(err) + } + }) + } +} diff --git a/api/http/server.go b/api/http/server.go index 7ea23eccc..0f6e5fbc8 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -7,6 +7,7 @@ import ( "path/filepath" "time" + "github.com/pkg/errors" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/adminmonitor" "github.com/portainer/portainer/api/apikey" @@ -15,6 +16,7 @@ import ( "github.com/portainer/portainer/api/demo" "github.com/portainer/portainer/api/docker" dockerclient "github.com/portainer/portainer/api/docker/client" + "github.com/portainer/portainer/api/http/csrf" "github.com/portainer/portainer/api/http/handler" "github.com/portainer/portainer/api/http/handler/auth" "github.com/portainer/portainer/api/http/handler/backup" @@ -91,7 +93,7 @@ type Server struct { GitService portainer.GitService OpenAMTService portainer.OpenAMTService APIKeyService apikey.APIKeyService - JWTService dataservices.JWTService + JWTService portainer.JWTService LDAPService portainer.LDAPService OAuthService portainer.OAuthService SwarmStackManager portainer.SwarmStackManager @@ -342,6 +344,11 @@ func (server *Server) Start() error { handler = middlewares.WithSlowRequestsLogger(handler) + handler, err := csrf.WithProtect(handler) + if err != nil { + return errors.Wrap(err, "failed to create CSRF middleware") + } + if server.HTTPEnabled { go func() { log.Info().Str("bind_address", server.BindAddress).Msg("starting HTTP server") diff --git a/api/internal/testhelpers/request_bouncer.go b/api/internal/testhelpers/request_bouncer.go index ea936de07..7f62d106e 100644 --- a/api/internal/testhelpers/request_bouncer.go +++ b/api/internal/testhelpers/request_bouncer.go @@ -50,6 +50,14 @@ func (testRequestBouncer) TrustedEdgeEnvironmentAccess(tx dataservices.DataStore return nil } -func (testRequestBouncer) JWTAuthLookup(r *http.Request) *portainer.TokenData { - return nil +func (testRequestBouncer) CookieAuthLookup(r *http.Request) (*portainer.TokenData, error) { + return nil, nil +} + +// AddTestSecurityCookie adds a security cookie to the request +func AddTestSecurityCookie(r *http.Request, jwt string) { + r.AddCookie(&http.Cookie{ + Name: portainer.AuthCookieKey, + Value: jwt, + }) } diff --git a/api/jwt/jwt.go b/api/jwt/jwt.go index 5fbcb02f7..88f7f2172 100644 --- a/api/jwt/jwt.go +++ b/api/jwt/jwt.go @@ -91,23 +91,15 @@ func getOrCreateKubeSecret(dataStore dataservices.DataStore) ([]byte, error) { return kubeSecret, nil } -func (service *Service) defaultExpireAt() int64 { - return time.Now().Add(service.userSessionTimeout).Unix() +func (service *Service) defaultExpireAt() time.Time { + return time.Now().Add(service.userSessionTimeout) } // GenerateToken generates a new JWT token. -func (service *Service) GenerateToken(data *portainer.TokenData) (string, error) { - return service.generateSignedToken(data, service.defaultExpireAt(), defaultScope) -} - -// GenerateTokenForOAuth generates a new JWT token for OAuth login -// token expiry time response from OAuth provider is considered -func (service *Service) GenerateTokenForOAuth(data *portainer.TokenData, expiryTime *time.Time) (string, error) { - expireAt := service.defaultExpireAt() - if expiryTime != nil && !expiryTime.IsZero() { - expireAt = expiryTime.Unix() - } - return service.generateSignedToken(data, expireAt, defaultScope) +func (service *Service) GenerateToken(data *portainer.TokenData) (string, time.Time, error) { + expiryTime := service.defaultExpireAt() + token, err := service.generateSignedToken(data, expiryTime.Unix(), defaultScope) + return token, expiryTime, err } // ParseAndVerifyToken parses a JWT token and verify its validity. It returns an error if token is invalid. diff --git a/api/kubernetes/cli/client.go b/api/kubernetes/cli/client.go index 428291a96..cf6993097 100644 --- a/api/kubernetes/cli/client.go +++ b/api/kubernetes/cli/client.go @@ -102,8 +102,8 @@ func (factory *ClientFactory) GetKubeClient(endpoint *portainer.Endpoint) (*Kube // GetProxyKubeClient retrieves a KubeClient from the cache. You should be // calling SetProxyKubeClient before first. It is normally, called the // kubernetes middleware. -func (factory *ClientFactory) GetProxyKubeClient(endpointID, token string) (*KubeClient, bool) { - client, ok := factory.endpointProxyClients.Get(endpointID + "." + token) +func (factory *ClientFactory) GetProxyKubeClient(endpointID, userID string) (*KubeClient, bool) { + client, ok := factory.endpointProxyClients.Get(endpointID + "." + userID) if !ok { return nil, false } @@ -112,8 +112,8 @@ func (factory *ClientFactory) GetProxyKubeClient(endpointID, token string) (*Kub } // SetProxyKubeClient stores a kubeclient in the cache. -func (factory *ClientFactory) SetProxyKubeClient(endpointID, token string, cli *KubeClient) { - factory.endpointProxyClients.Set(endpointID+"."+token, cli, 0) +func (factory *ClientFactory) SetProxyKubeClient(endpointID, userID string, cli *KubeClient) { + factory.endpointProxyClients.Set(endpointID+"."+userID, cli, 0) } // CreateKubeClientFromKubeConfig creates a KubeClient from a clusterID, and diff --git a/api/portainer.go b/api/portainer.go index 027a572cf..97bd36cb6 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -1478,6 +1478,14 @@ type ( ExecuteDeviceAction(configuration OpenAMTConfiguration, deviceGUID string, action string) error } + // JWTService represents a service for managing JWT tokens + JWTService interface { + GenerateToken(data *TokenData) (string, time.Time, error) + GenerateTokenForKubeconfig(data *TokenData) (string, error) + ParseAndVerifyToken(token string) (*TokenData, error) + SetUserSessionDuration(userSessionDuration time.Duration) + } + // KubeClient represents a service used to query a Kubernetes environment(endpoint) KubeClient interface { SetupUserServiceAccount(userID int, teamIDs []int, restrictDefaultNamespace bool) error @@ -1627,6 +1635,8 @@ const ( DefaultKubectlShellImage = "portainer/kubectl-shell" // WebSocketKeepAlive web socket keep alive for edge environments WebSocketKeepAlive = 1 * time.Hour + // AuthCookieName is the name of the cookie used to store the JWT token + AuthCookieKey = "portainer_api_key" ) // List of supported features diff --git a/app/app.js b/app/app.js index fc7d309e9..c331b9a4e 100644 --- a/app/app.js +++ b/app/app.js @@ -1,7 +1,7 @@ import $ from 'jquery'; /* @ngInject */ -export function onStartupAngular($rootScope, $state, LocalStorage, cfpLoadingBar, $transitions, HttpRequestHelper) { +export function onStartupAngular($rootScope, $state, cfpLoadingBar, $transitions, HttpRequestHelper) { $rootScope.$state = $state; // Workaround to prevent the loading bar from going backward @@ -23,6 +23,7 @@ export function onStartupAngular($rootScope, $state, LocalStorage, cfpLoadingBar if (type && hasNoContentType) { jqXhr.setRequestHeader('Content-Type', 'application/json'); } - jqXhr.setRequestHeader('Authorization', 'Bearer ' + LocalStorage.getJWT()); + const csrfCookie = window.cookieStore.get('_gorilla_csrf'); + jqXhr.setRequestHeader('X-CSRF-Token', csrfCookie); }); } diff --git a/app/config.js b/app/config.js index fc72f3d22..d46b5d17e 100644 --- a/app/config.js +++ b/app/config.js @@ -1,24 +1,16 @@ import { Terminal } from 'xterm'; import * as fit from 'xterm/lib/addons/fit/fit'; +import { csrfInterceptor, csrfTokenReaderInterceptorAngular } from './portainer/services/csrf'; import { agentInterceptor } from './portainer/services/axios'; /* @ngInject */ -export function configApp($urlRouterProvider, $httpProvider, localStorageServiceProvider, jwtOptionsProvider, $uibTooltipProvider, $compileProvider, cfpLoadingBarProvider) { +export function configApp($urlRouterProvider, $httpProvider, localStorageServiceProvider, $uibTooltipProvider, $compileProvider, cfpLoadingBarProvider) { if (process.env.NODE_ENV === 'testing') { $compileProvider.debugInfoEnabled(false); } localStorageServiceProvider.setPrefix('portainer'); - jwtOptionsProvider.config({ - tokenGetter: /* @ngInject */ function tokenGetter(LocalStorage) { - return LocalStorage.getJWT(); - }, - whiteListedDomains: ['localhost'], - }); - - $httpProvider.interceptors.push('jwtInterceptor'); - $httpProvider.defaults.headers.post['Content-Type'] = 'application/json'; $httpProvider.defaults.headers.put['Content-Type'] = 'application/json'; $httpProvider.defaults.headers.patch['Content-Type'] = 'application/json'; @@ -27,6 +19,11 @@ export function configApp($urlRouterProvider, $httpProvider, localStorageService request: agentInterceptor, })); + $httpProvider.interceptors.push(() => ({ + response: csrfTokenReaderInterceptorAngular, + request: csrfInterceptor, + })); + Terminal.applyAddon(fit); $uibTooltipProvider.setTriggers({ diff --git a/app/docker/views/containers/console/containerConsoleController.js b/app/docker/views/containers/console/containerConsoleController.js index 553c6e19e..81b9df4f1 100644 --- a/app/docker/views/containers/console/containerConsoleController.js +++ b/app/docker/views/containers/console/containerConsoleController.js @@ -168,8 +168,6 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [ url += '&nodeName=' + $transition$.params().nodeName; } - url += '&token=' + LocalStorage.getJWT(); - if (url.indexOf('https') > -1) { url = url.replace('https://', 'wss://'); } else { diff --git a/app/index.js b/app/index.js index e44ed80e8..bbb18e5f1 100644 --- a/app/index.js +++ b/app/index.js @@ -34,7 +34,6 @@ angular 'ngResource', 'angularUtils.directives.dirPagination', 'LocalStorageModule', - 'angular-jwt', 'angular-json-tree', 'angular-loading-bar', 'angular-clipboard', diff --git a/app/portainer/__module.js b/app/portainer/__module.js index a00d3e84e..9fb533f6e 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -1,5 +1,3 @@ -import _ from 'lodash-es'; - import featureFlagModule from '@/react/portainer/feature-flags'; import './rbac'; @@ -12,35 +10,8 @@ import { reactModule } from './react'; import { sidebarModule } from './react/views/sidebar'; import environmentsModule from './environments'; import { helpersModule } from './helpers'; -import { AXIOS_UNAUTHENTICATED } from './services/axios'; - -async function initAuthentication(authManager, Authentication, $rootScope, $state) { - authManager.checkAuthOnRefresh(); - - function handleUnauthenticated(data, performReload) { - if (!_.includes(data.config.url, '/v2/') && !_.includes(data.config.url, '/api/v4/') && isTransitionRequiresAuthentication($state.transition)) { - $state.go('portainer.logout', { error: 'Your session has expired' }); - if (performReload) { - window.location.reload(); - } - } - } - - // The unauthenticated event is broadcasted by the jwtInterceptor when - // hitting a 401. We're using this instead of the usual combination of - // authManager.redirectWhenUnauthenticated() + unauthenticatedRedirector - // to have more controls on which URL should trigger the unauthenticated state. - $rootScope.$on('unauthenticated', function (event, data) { - handleUnauthenticated(data, true); - }); - - // the AXIOS_UNAUTHENTICATED event is emitted by axios when a request returns with a 401 code - // the event contains the entire AxiosError in detail.err - window.addEventListener(AXIOS_UNAUTHENTICATED, (event) => { - const data = event.detail.err; - handleUnauthenticated(data); - }); +async function initAuthentication(Authentication) { return await Authentication.init(); } @@ -65,14 +36,14 @@ angular var root = { name: 'root', abstract: true, - onEnter: /* @ngInject */ function onEnter($async, StateManager, Authentication, Notifications, authManager, $rootScope, $state) { + onEnter: /* @ngInject */ function onEnter($async, StateManager, Authentication, Notifications, $state) { return $async(async () => { const appState = StateManager.getState(); if (!appState.loading) { return; } try { - const loggedIn = await initAuthentication(authManager, Authentication, $rootScope, $state); + const loggedIn = await initAuthentication(Authentication); await StateManager.initialize(); if (!loggedIn && isTransitionRequiresAuthentication($state.transition)) { $state.go('portainer.logout'); diff --git a/app/portainer/services/authentication.js b/app/portainer/services/authentication.js index df7d990e0..c793ef2c9 100644 --- a/app/portainer/services/authentication.js +++ b/app/portainer/services/authentication.js @@ -1,3 +1,4 @@ +import { getCurrentUser } from '../users/queries/useLoadCurrentUser'; import { clear as clearSessionStorage } from './session-storage'; const DEFAULT_USER = 'admin'; @@ -7,13 +8,11 @@ angular.module('portainer.app').factory('Authentication', [ '$async', 'Auth', 'OAuth', - 'jwtHelper', 'LocalStorage', 'StateManager', 'EndpointProvider', - 'UserService', 'ThemeManager', - function AuthenticationFactory($async, Auth, OAuth, jwtHelper, LocalStorage, StateManager, EndpointProvider, UserService, ThemeManager) { + function AuthenticationFactory($async, Auth, OAuth, LocalStorage, StateManager, EndpointProvider, ThemeManager) { 'use strict'; var service = {}; @@ -29,11 +28,12 @@ angular.module('portainer.app').factory('Authentication', [ async function initAsync() { try { - const jwt = LocalStorage.getJWT(); - if (!jwt || jwtHelper.isTokenExpired(jwt)) { - return tryAutoLoginExtension(); + const userId = LocalStorage.getUserId(); + if (userId && user.ID === userId) { + return true; } - await setUser(jwt); + await tryAutoLoginExtension(); + await loadUserData(); return true; } catch (error) { return tryAutoLoginExtension(); @@ -62,16 +62,8 @@ angular.module('portainer.app').factory('Authentication', [ } async function OAuthLoginAsync(code) { - const response = await OAuth.validate({ code: code }).$promise; - const jwt = setJWTFromResponse(response); - await setUser(jwt); - } - - function setJWTFromResponse(response) { - const jwt = response.jwt; - LocalStorage.storeJWT(jwt); - - return response.jwt; + await OAuth.validate({ code: code }).$promise; + await loadUserData(); } function OAuthLogin(code) { @@ -79,9 +71,8 @@ angular.module('portainer.app').factory('Authentication', [ } async function loginAsync(username, password) { - const response = await Auth.login({ username: username, password: password }).$promise; - const jwt = setJWTFromResponse(response); - await setUser(jwt); + await Auth.login({ username: username, password: password }).$promise; + await loadUserData(); } function login(username, password) { @@ -89,33 +80,31 @@ angular.module('portainer.app').factory('Authentication', [ } function isAuthenticated() { - var jwt = LocalStorage.getJWT(); - return !!jwt && !jwtHelper.isTokenExpired(jwt); + return !!user.ID; } function getUserDetails() { return user; } - async function setUserTheme() { - const data = await UserService.user(user.ID); + async function loadUserData() { + const userData = await getCurrentUser(); + user.username = userData.Username; + user.ID = userData.Id; + user.role = userData.Role; + user.forceChangePassword = userData.forceChangePassword; + user.endpointAuthorizations = userData.EndpointAuthorizations; + user.portainerAuthorizations = userData.PortainerAuthorizations; // Initialize user theme base on UserTheme from database - const userTheme = data.ThemeSettings ? data.ThemeSettings.color : 'auto'; + const userTheme = userData.ThemeSettings ? userData.ThemeSettings.color : 'auto'; if (userTheme === 'auto' || !userTheme) { ThemeManager.autoTheme(); } else { ThemeManager.setTheme(userTheme); } - } - async function setUser(jwt) { - var tokenPayload = jwtHelper.decodeToken(jwt); - user.username = tokenPayload.username; - user.ID = tokenPayload.id; - user.role = tokenPayload.role; - user.forceChangePassword = tokenPayload.forceChangePassword; - await setUserTheme(); + LocalStorage.storeUserId(userData.Id); } function tryAutoLoginExtension() { diff --git a/app/portainer/services/axios.ts b/app/portainer/services/axios.ts index d2648ade2..73e3efeab 100644 --- a/app/portainer/services/axios.ts +++ b/app/portainer/services/axios.ts @@ -3,7 +3,6 @@ import { loadProgressBar } from 'axios-progress-bar'; import 'axios-progress-bar/dist/nprogress.css'; import PortainerError from '@/portainer/error'; -import { get as localStorageGet } from '@/react/hooks/useLocalStorage'; import { portainerAgentManagerOperation, @@ -16,17 +15,6 @@ loadProgressBar(undefined, axios); export default axios; -axios.interceptors.request.use(async (config) => { - const newConfig = { headers: config.headers || {}, ...config }; - - const jwt = localStorageGet('JWT', ''); - if (jwt) { - newConfig.headers.Authorization = `Bearer ${jwt}`; - } - - return newConfig; -}); - export const agentTargetHeader = 'X-PortainerAgent-Target'; export function agentInterceptor(config: AxiosRequestConfig) { @@ -49,7 +37,33 @@ export function agentInterceptor(config: AxiosRequestConfig) { axios.interceptors.request.use(agentInterceptor); -export const AXIOS_UNAUTHENTICATED = '__axios__unauthenticated__'; +axios.interceptors.response.use(undefined, (error) => { + if ( + error.response?.status === 401 && + !error.config.url.includes('/v2/') && + !error.config.url.includes('/api/v4/') && + isTransitionRequiresAuthentication() + ) { + // eslint-disable-next-line no-console + console.error('Unauthorized request, logging out'); + window.location.hash = '/logout'; + window.location.reload(); + } + + return Promise.reject(error); +}); + +const UNAUTHENTICATED_ROUTES = [ + '/logout', + '/internal-auth', + '/auth', + '/init/admin', +]; +function isTransitionRequiresAuthentication() { + return !UNAUTHENTICATED_ROUTES.some((route) => + window.location.hash.includes(route) + ); +} /** * Parses an Axios error and returns a PortainerError. @@ -74,16 +88,6 @@ export function parseAxiosError( } else { resultMsg = msg || details; } - // dispatch an event for unauthorized errors that AngularJS can catch - if (err.response?.status === 401) { - dispatchEvent( - new CustomEvent(AXIOS_UNAUTHENTICATED, { - detail: { - err, - }, - }) - ); - } } return new PortainerError(resultMsg, resultErr); diff --git a/app/portainer/services/csrf.ts b/app/portainer/services/csrf.ts new file mode 100644 index 000000000..4dbb813cd --- /dev/null +++ b/app/portainer/services/csrf.ts @@ -0,0 +1,37 @@ +import { AxiosRequestConfig, AxiosResponse } from 'axios'; +import { IHttpResponse } from 'angular'; + +import axios from './axios'; + +axios.interceptors.response.use(csrfTokenReaderInterceptor); +axios.interceptors.request.use(csrfInterceptor); + +let csrfToken: string | null = null; + +export function csrfTokenReaderInterceptor(config: AxiosResponse) { + const csrfTokenHeader = config.headers['x-csrf-token']; + if (csrfTokenHeader) { + csrfToken = csrfTokenHeader; + } + return config; +} + +export function csrfTokenReaderInterceptorAngular( + config: IHttpResponse +) { + const csrfTokenHeader = config.headers('x-csrf-token'); + if (csrfTokenHeader) { + csrfToken = csrfTokenHeader; + } + return config; +} + +export function csrfInterceptor(config: AxiosRequestConfig) { + if (!csrfToken) { + return config; + } + + const newConfig = { headers: config.headers || {}, ...config }; + newConfig.headers['X-CSRF-Token'] = csrfToken; + return newConfig; +} diff --git a/app/portainer/services/localStorage.js b/app/portainer/services/localStorage.js index 78dba63a9..d00150d6c 100644 --- a/app/portainer/services/localStorage.js +++ b/app/portainer/services/localStorage.js @@ -29,14 +29,14 @@ angular.module('portainer.app').factory('LocalStorage', [ getUIState: function () { return localStorageService.get('UI_STATE'); }, - storeJWT: function (jwt) { - localStorageService.set('JWT', jwt); + getUserId() { + localStorageService.get('USER_ID'); }, - getJWT: function () { - return localStorageService.get('JWT'); + storeUserId: function (userId) { + localStorageService.set('USER_ID', userId); }, - deleteJWT: function () { - localStorageService.remove('JWT'); + deleteUserId: function () { + localStorageService.remove('USER_ID'); }, storePaginationLimit: function (key, count) { localStorageService.set('datatable_pagination_' + key, count); @@ -119,7 +119,7 @@ angular.module('portainer.app').factory('LocalStorage', [ localStorageService.clearAll(); }, cleanAuthData() { - localStorageService.remove('JWT', 'APPLICATION_STATE', 'LOGIN_STATE_UUID', 'ALLOWED_NAMESPACES'); + localStorageService.remove('USER_ID', 'APPLICATION_STATE', 'LOGIN_STATE_UUID', 'ALLOWED_NAMESPACES'); }, storeKubernetesSummaryToggle(value) { localStorageService.set('kubernetes_summary_expanded', value); diff --git a/app/portainer/services/notifications.ts b/app/portainer/services/notifications.ts index 22a39a8dc..bfbebd3f6 100644 --- a/app/portainer/services/notifications.ts +++ b/app/portainer/services/notifications.ts @@ -1,7 +1,6 @@ import _ from 'lodash'; import toastr from 'toastr'; import sanitize from 'sanitize-html'; -import jwtDecode from 'jwt-decode'; import { v4 as uuid } from 'uuid'; import { get as localStorageGet } from '@/react/hooks/useLocalStorage'; @@ -109,11 +108,8 @@ function saveNotification(title: string, text: string, type: string) { type, timeStamp: new Date(), }; - const jwt = localStorageGet('JWT', ''); - if (jwt !== '') { - const { id } = jwtDecode(jwt) as { id: number }; - if (id) { - addNotification(id, notif); - } + const userId = localStorageGet('USER_ID', ''); + if (userId !== '') { + addNotification(userId, notif); } } diff --git a/app/portainer/users/queries/queryKeys.ts b/app/portainer/users/queries/queryKeys.ts index c6f335d98..38a56fb7c 100644 --- a/app/portainer/users/queries/queryKeys.ts +++ b/app/portainer/users/queries/queryKeys.ts @@ -3,4 +3,5 @@ import { UserId } from '../types'; export const queryKeys = { base: () => ['users'] as const, user: (id: UserId) => [...queryKeys.base(), id] as const, + me: () => [...queryKeys.base(), 'me'] as const, }; diff --git a/app/portainer/users/queries/useLoadCurrentUser.ts b/app/portainer/users/queries/useLoadCurrentUser.ts new file mode 100644 index 000000000..bb68dd8b8 --- /dev/null +++ b/app/portainer/users/queries/useLoadCurrentUser.ts @@ -0,0 +1,32 @@ +import { useQuery } from 'react-query'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { withError } from '@/react-tools/react-query'; + +import { buildUrl } from '../user.service'; +import { User } from '../types'; + +import { queryKeys } from './queryKeys'; + +interface CurrentUserResponse extends User { + forceChangePassword: boolean; +} + +export function useLoadCurrentUser({ staleTime }: { staleTime?: number } = {}) { + return useQuery(queryKeys.me(), () => getCurrentUser(), { + ...withError('Unable to retrieve user details'), + staleTime, + }); +} + +export async function getCurrentUser() { + try { + const { data: user } = await axios.get( + buildUrl(undefined, 'me') + ); + + return user; + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to retrieve user details'); + } +} diff --git a/app/react/hooks/useUser.tsx b/app/react/hooks/useUser.tsx index 7088e26eb..0045f76d8 100644 --- a/app/react/hooks/useUser.tsx +++ b/app/react/hooks/useUser.tsx @@ -1,4 +1,3 @@ -import jwtDecode from 'jwt-decode'; import { useCurrentStateAndParams } from '@uirouter/react'; import { createContext, @@ -11,9 +10,7 @@ import { import { isAdmin } from '@/portainer/users/user.helpers'; import { EnvironmentId } from '@/react/portainer/environments/types'; import { User } from '@/portainer/users/types'; -import { useUser as useLoadUser } from '@/portainer/users/queries/useUser'; - -import { useLocalStorage } from './useLocalStorage'; +import { useLoadCurrentUser } from '@/portainer/users/queries/useLoadCurrentUser'; interface State { user?: User; @@ -163,20 +160,14 @@ interface UserProviderProps { } export function UserProvider({ children }: UserProviderProps) { - const [jwt] = useLocalStorage('JWT', ''); - - const tokenPayload = useMemo(() => jwtDecode(jwt) as { id: number }, [jwt]); - - const userQuery = useLoadUser(tokenPayload.id, { - staleTime: Infinity, // should reload te user details only on page load - }); + const userQuery = useLoadCurrentUser(); const providerState = useMemo( () => ({ user: userQuery.data }), [userQuery.data] ); - if (jwt === '' || !providerState.user) { + if (!providerState.user) { return null; } diff --git a/app/react/kubernetes/applications/ConsoleView/ConsoleView.tsx b/app/react/kubernetes/applications/ConsoleView/ConsoleView.tsx index 5a2d833cc..17c3bf117 100644 --- a/app/react/kubernetes/applications/ConsoleView/ConsoleView.tsx +++ b/app/react/kubernetes/applications/ConsoleView/ConsoleView.tsx @@ -3,7 +3,6 @@ import { useCurrentStateAndParams } from '@uirouter/react'; import { Terminal as TerminalIcon } from 'lucide-react'; import { Terminal } from 'xterm'; -import { get } from '@/react/hooks/useLocalStorage'; import { baseHref } from '@/portainer/helpers/pathHelper'; import { notifyError } from '@/portainer/services/notifications'; @@ -169,10 +168,7 @@ export function ConsoleView() { ); function connectConsole() { - const jwtToken = get('JWT', ''); - const params: StringDictionary = { - token: jwtToken, endpointId: environmentId, namespace, podName: podID, diff --git a/app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShell.tsx b/app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShell.tsx index 30e17d460..c9a754c66 100644 --- a/app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShell.tsx +++ b/app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShell.tsx @@ -11,7 +11,6 @@ import { } from '@/portainer/services/terminal-window'; import { EnvironmentId } from '@/react/portainer/environments/types'; import { error as notifyError } from '@/portainer/services/notifications'; -import { useLocalStorage } from '@/react/hooks/useLocalStorage'; import { Icon } from '@@/Icon'; import { Button } from '@@/buttons'; @@ -40,8 +39,6 @@ export function KubeCtlShell({ environmentId, onClose }: Props) { const terminalElem = useRef(null); - const [jwt] = useLocalStorage('JWT', ''); - const handleClose = useCallback(() => { terminalClose(); // only css trick socket?.close(); @@ -103,7 +100,7 @@ export function KubeCtlShell({ environmentId, onClose }: Props) { // on component load/destroy useEffect(() => { - const socket = new WebSocket(buildUrl(jwt, environmentId)); + const socket = new WebSocket(buildUrl(environmentId)); setShell((shell) => ({ ...shell, socket })); terminal.onData((data) => socket.send(data)); @@ -122,7 +119,7 @@ export function KubeCtlShell({ environmentId, onClose }: Props) { } return close; - }, [environmentId, jwt, terminal]); + }, [environmentId, terminal]); return (
@@ -182,9 +179,8 @@ export function KubeCtlShell({ environmentId, onClose }: Props) { } } - function buildUrl(jwt: string, environmentId: EnvironmentId) { + function buildUrl(environmentId: EnvironmentId) { const params = { - token: jwt, endpointId: environmentId, }; diff --git a/app/vendors.js b/app/vendors.js index 72ca2416b..ac147a8bb 100644 --- a/app/vendors.js +++ b/app/vendors.js @@ -6,7 +6,6 @@ import 'angular-messages'; import 'angular-resource'; import 'angular-utils-pagination'; import 'angular-local-storage'; -import 'angular-jwt'; import 'angular-json-tree'; import 'angular-loading-bar'; import 'angular-clipboard'; diff --git a/go.mod b/go.mod index dde2f70d3..14c52e8d5 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/gofrs/uuid v4.2.0+incompatible github.com/golang-jwt/jwt/v4 v4.2.0 github.com/google/go-cmp v0.5.9 + github.com/gorilla/csrf v1.7.1 github.com/gorilla/handlers v1.5.1 github.com/gorilla/mux v1.8.0 github.com/gorilla/websocket v1.5.0 @@ -40,6 +41,7 @@ require ( github.com/rs/zerolog v1.29.0 github.com/segmentio/encoding v0.3.6 github.com/stretchr/testify v1.8.4 + github.com/urfave/negroni v1.0.0 github.com/viney-shih/go-lock v1.1.1 go.etcd.io/bbolt v1.3.7 golang.org/x/crypto v0.14.0 @@ -56,6 +58,8 @@ require ( software.sslmate.com/src/go-pkcs12 v0.0.0-20210415151418-c5206de65a78 ) +require github.com/gorilla/securecookie v1.1.1 // indirect + require ( github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect github.com/Azure/go-ntlmssp v0.0.0-20200615164410-66371956d46c // indirect diff --git a/go.sum b/go.sum index 62ae6004f..04543219f 100644 --- a/go.sum +++ b/go.sum @@ -186,10 +186,14 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaU github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/csrf v1.7.1 h1:Ir3o2c1/Uzj6FBxMlAUB6SivgVMy1ONXwYgXn+/aHPE= +github.com/gorilla/csrf v1.7.1/go.mod h1:+a/4tCmqhG6/w4oafeAZ9pEa3/NZOWYVbD9fV0FwIQA= github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4= github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= @@ -338,6 +342,8 @@ github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce h1:fb190+cK2Xz/dvi9 github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4= github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8= github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/urfave/negroni v1.0.0 h1:kIimOitoypq34K7TG7DUaJ9kq/N4Ofuwi1sjz0KipXc= +github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4= github.com/vbatts/tar-split v0.11.5 h1:3bHCTIheBm1qFTcgh9oPu+nNBtX+XJIupG/vacinCts= github.com/vbatts/tar-split v0.11.5/go.mod h1:yZbwRsSeGjusneWgA781EKej9HF8vme8okylkAeNKLk= github.com/viney-shih/go-lock v1.1.1 h1:SwzDPPAiHpcwGCr5k8xD15d2gQSo8d4roRYd7TDV2eI= diff --git a/package.json b/package.json index 40417fedc..63eafd6da 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,6 @@ "angular-clipboard": "^1.6.2", "angular-file-saver": "^1.1.3", "angular-json-tree": "1.1.0", - "angular-jwt": "~0.1.8", "angular-loading-bar": "~0.9.0", "angular-local-storage": "~0.5.2", "angular-messages": "1.8.2", @@ -97,7 +96,6 @@ "jquery": "^3.6.0", "js-base64": "^3.7.2", "js-yaml": "^3.14.0", - "jwt-decode": "^3.1.2", "lodash": "^4.17.21", "lucide-react": "^0.101.0", "moment": "^2.29.1", diff --git a/yarn.lock b/yarn.lock index 855a9958a..0570dc7b9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6059,11 +6059,6 @@ angular-json-tree@1.1.0: resolved "https://registry.yarnpkg.com/angular-json-tree/-/angular-json-tree-1.1.0.tgz#d7faba97130fc273fa29ef517dbed10342cc645e" integrity sha512-HVLyrVkEoYVykcIgzMCdhtK2H8Y4jgNujGNqRXNG4x032tp2ZWp34j/hu/E7h6a7X+ODrSTAfRTbkF4f/JX/Fg== -angular-jwt@~0.1.8: - version "0.1.11" - resolved "https://registry.yarnpkg.com/angular-jwt/-/angular-jwt-0.1.11.tgz#a72b7583c7cf2f2d7acda169a4114141c83f9c91" - integrity sha512-793dv5vXOXaW5/cweMd+sqSf9dPhbazDya3szTVOQ84MDEj1nYLJrixBBa7WNtZeMqz7ylWX7djnFgqLjEWcHw== - angular-loading-bar@~0.9.0: version "0.9.0" resolved "https://registry.yarnpkg.com/angular-loading-bar/-/angular-loading-bar-0.9.0.tgz#37ef52c25f102c216e7b3cdfd2fc5a5df9628e45" @@ -11883,11 +11878,6 @@ jsx-ast-utils@^3.3.3: array-includes "^3.1.5" object.assign "^4.1.3" -jwt-decode@^3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-3.1.2.tgz#3fb319f3675a2df0c2895c8f5e9fa4b67b04ed59" - integrity sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A== - kind-of@^6.0.2: version "6.0.3" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"