feat(auth): save jwt in cookie [EE-5864] (#10527)

pull/10665/head
Chaim Lev-Ari 1 year ago committed by GitHub
parent ecce501cf3
commit 436da01bce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -34,7 +34,6 @@ angular
'ngResource',
'angularUtils.directives.dirPagination',
'LocalStorageModule',
'angular-jwt',
'angular-json-tree',
'angular-loading-bar',
'angular-clipboard',

@ -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');

@ -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() {

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

@ -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<unknown>
) {
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;
}

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

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

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

@ -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<CurrentUserResponse>(
buildUrl(undefined, 'me')
);
return user;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to retrieve user details');
}
}

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

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

@ -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 (
<div className={clsx(styles.root, { [styles.minimized]: shell.minimized })}>
@ -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,
};

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

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

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

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

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

Loading…
Cancel
Save