mirror of https://github.com/portainer/portainer
feat(auth): save jwt in cookie [EE-5864] (#10527)
parent
ecce501cf3
commit
436da01bce
|
@ -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
|
||||
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
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
4
go.mod
4
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
|
||||
|
|
6
go.sum
6
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=
|
||||
|
|
|
@ -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",
|
||||
|
|
10
yarn.lock
10
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"
|
||||
|
|
Loading…
Reference in New Issue