diff --git a/api/bolt/init.go b/api/bolt/init.go index 9209cfc3a..1c997087e 100644 --- a/api/bolt/init.go +++ b/api/bolt/init.go @@ -45,6 +45,7 @@ func (store *Store) Init() error { EdgeAgentCheckinInterval: portainer.DefaultEdgeAgentCheckinIntervalInSeconds, TemplatesURL: portainer.DefaultTemplatesURL, UserSessionTimeout: portainer.DefaultUserSessionTimeout, + KubeconfigExpiry: portainer.DefaultKubeconfigExpiry, } err = store.SettingsService.UpdateSettings(defaultSettings) diff --git a/api/bolt/migrator/migrate_dbversion31.go b/api/bolt/migrator/migrate_dbversion31.go index 52f6c569b..6c9f7f00b 100644 --- a/api/bolt/migrator/migrate_dbversion31.go +++ b/api/bolt/migrator/migrate_dbversion31.go @@ -24,6 +24,10 @@ func (m *Migrator) migrateDBVersionToDB32() error { return err } + if err := m.kubeconfigExpiryToDB32(); err != nil { + return err + } + return nil } @@ -211,3 +215,12 @@ func findResourcesToUpdateForDB32(dockerID string, volumesData map[string]interf } } } + +func (m *Migrator) kubeconfigExpiryToDB32() error { + settings, err := m.settingsService.Settings() + if err != nil { + return err + } + settings.KubeconfigExpiry = portainer.DefaultKubeconfigExpiry + return m.settingsService.UpdateSettings(settings) +} \ No newline at end of file diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 717c3fbc3..32074c7e5 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -114,7 +114,7 @@ func initJWTService(dataStore portainer.DataStore) (portainer.JWTService, error) settings.UserSessionTimeout = portainer.DefaultUserSessionTimeout dataStore.Settings().UpdateSettings(settings) } - jwtService, err := jwt.NewService(settings.UserSessionTimeout) + jwtService, err := jwt.NewService(settings.UserSessionTimeout, dataStore) if err != nil { return nil, err } diff --git a/api/http/handler/kubernetes/handler.go b/api/http/handler/kubernetes/handler.go index 3efad11f4..015b063c9 100644 --- a/api/http/handler/kubernetes/handler.go +++ b/api/http/handler/kubernetes/handler.go @@ -20,6 +20,7 @@ type Handler struct { dataStore portainer.DataStore kubernetesClientFactory *cli.ClientFactory authorizationService *authorization.Service + JwtService portainer.JWTService } // NewHandler creates a handler to process pre-proxied requests to external APIs. diff --git a/api/http/handler/kubernetes/kubernetes_config.go b/api/http/handler/kubernetes/kubernetes_config.go index 972d34390..31734dcb1 100644 --- a/api/http/handler/kubernetes/kubernetes_config.go +++ b/api/http/handler/kubernetes/kubernetes_config.go @@ -3,14 +3,11 @@ package kubernetes import ( "errors" "fmt" - "strings" - httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" - httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" kcli "github.com/portainer/portainer/api/kubernetes/cli" @@ -46,16 +43,16 @@ func (handler *Handler) getKubernetesConfig(w http.ResponseWriter, r *http.Reque return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} } - bearerToken, err := extractBearerToken(r) - if err != nil { - return &httperror.HandlerError{http.StatusUnauthorized, "Unauthorized", err} - } - tokenData, err := security.RetrieveTokenData(r) if err != nil { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } + bearerToken, err := handler.JwtService.GenerateTokenForKubeconfig(tokenData) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to generate JWT token", err} + } + cli, err := handler.kubernetesClientFactory.GetKubeClient(endpoint) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create Kubernetes client", err} @@ -84,20 +81,6 @@ func (handler *Handler) getKubernetesConfig(w http.ResponseWriter, r *http.Reque return response.JSON(w, config) } -// extractBearerToken extracts user's portainer bearer token from request auth header -func extractBearerToken(r *http.Request) (string, error) { - token := "" - tokens := r.Header["Authorization"] - if len(tokens) >= 1 { - token = tokens[0] - token = strings.TrimPrefix(token, "Bearer ") - } - if token == "" { - return "", httperrors.ErrUnauthorized - } - return token, nil -} - // getProxyUrl generates portainer proxy url which acts as proxy to k8s api server func getProxyUrl(r *http.Request, endpointID int) string { return fmt.Sprintf("https://%s/api/endpoints/%d/kubernetes", r.Host, endpointID) diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go index 36150d536..7258170d9 100644 --- a/api/http/handler/settings/settings_update.go +++ b/api/http/handler/settings/settings_update.go @@ -32,6 +32,8 @@ type settingsUpdatePayload struct { EnableEdgeComputeFeatures *bool `example:"true"` // The duration of a user session UserSessionTimeout *string `example:"5m"` + // The expiry of a Kubeconfig + KubeconfigExpiry *string `example:"24h" default:"0"` // Whether telemetry is enabled EnableTelemetry *bool `example:"false"` } @@ -52,6 +54,12 @@ func (payload *settingsUpdatePayload) Validate(r *http.Request) error { return errors.New("Invalid user session timeout") } } + if payload.KubeconfigExpiry != nil { + _, err := time.ParseDuration(*payload.KubeconfigExpiry) + if err != nil { + return errors.New("Invalid Kubeconfig Expiry") + } + } return nil } @@ -135,6 +143,10 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) * settings.EdgeAgentCheckinInterval = *payload.EdgeAgentCheckinInterval } + if payload.KubeconfigExpiry != nil { + settings.KubeconfigExpiry = *payload.KubeconfigExpiry + } + if payload.UserSessionTimeout != nil { settings.UserSessionTimeout = *payload.UserSessionTimeout diff --git a/api/http/server.go b/api/http/server.go index 95f9dfa76..88ca1284f 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -161,6 +161,7 @@ func (server *Server) Start() error { endpointProxyHandler.ReverseTunnelService = server.ReverseTunnelService var kubernetesHandler = kubehandler.NewHandler(requestBouncer, server.AuthorizationService, server.DataStore, server.KubernetesClientFactory) + kubernetesHandler.JwtService = server.JWTService var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public")) diff --git a/api/internal/testhelpers/datastore.go b/api/internal/testhelpers/datastore.go index 635508d9b..08e865c77 100644 --- a/api/internal/testhelpers/datastore.go +++ b/api/internal/testhelpers/datastore.go @@ -70,6 +70,21 @@ func NewDatastore(options ...datastoreOption) *datastore { return &d } + +type stubSettingsService struct { + settings *portainer.Settings +} + +func (s *stubSettingsService) Settings() (*portainer.Settings, error) { return s.settings, nil } +func (s *stubSettingsService) UpdateSettings(settings *portainer.Settings) error { return nil } + +func WithSettings(settings *portainer.Settings) datastoreOption { + return func(d *datastore) { + d.settings = &stubSettingsService{settings: settings} + } +} + + type stubUserService struct { users []portainer.User } diff --git a/api/jwt/jwt.go b/api/jwt/jwt.go index 2caf0840a..5786caf85 100644 --- a/api/jwt/jwt.go +++ b/api/jwt/jwt.go @@ -16,6 +16,7 @@ import ( type Service struct { secret []byte userSessionTimeout time.Duration + dataStore portainer.DataStore } type claims struct { @@ -31,7 +32,7 @@ var ( ) // NewService initializes a new service. It will generate a random key that will be used to sign JWT tokens. -func NewService(userSessionDuration string) (*Service, error) { +func NewService(userSessionDuration string, dataStore portainer.DataStore) (*Service, error) { userSessionTimeout, err := time.ParseDuration(userSessionDuration) if err != nil { return nil, err @@ -45,19 +46,28 @@ func NewService(userSessionDuration string) (*Service, error) { service := &Service{ secret, userSessionTimeout, + dataStore, } return service, nil } +func (service *Service) defaultExpireAt() (int64) { + return time.Now().Add(service.userSessionTimeout).Unix() +} + // GenerateToken generates a new JWT token. func (service *Service) GenerateToken(data *portainer.TokenData) (string, error) { - return service.generateSignedToken(data, nil) + return service.generateSignedToken(data, service.defaultExpireAt()) } // GenerateTokenForOAuth generates a new JWT for OAuth login // token expiry time from the OAuth provider is considered func (service *Service) GenerateTokenForOAuth(data *portainer.TokenData, expiryTime *time.Time) (string, error) { - return service.generateSignedToken(data, expiryTime) + expireAt := service.defaultExpireAt() + if expiryTime != nil && !expiryTime.IsZero() { + expireAt = expiryTime.Unix() + } + return service.generateSignedToken(data, expireAt) } // ParseAndVerifyToken parses a JWT token and verify its validity. It returns an error if token is invalid. @@ -88,17 +98,13 @@ func (service *Service) SetUserSessionDuration(userSessionDuration time.Duration service.userSessionTimeout = userSessionDuration } -func (service *Service) generateSignedToken(data *portainer.TokenData, expiryTime *time.Time) (string, error) { - expireToken := time.Now().Add(service.userSessionTimeout).Unix() - if expiryTime != nil && !expiryTime.IsZero() { - expireToken = expiryTime.Unix() - } +func (service *Service) generateSignedToken(data *portainer.TokenData, expiresAt int64) (string, error) { cl := claims{ UserID: int(data.ID), Username: data.Username, Role: int(data.Role), StandardClaims: jwt.StandardClaims{ - ExpiresAt: expireToken, + ExpiresAt: expiresAt, }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, cl) diff --git a/api/jwt/jwt_kubeconfig.go b/api/jwt/jwt_kubeconfig.go new file mode 100644 index 000000000..544a481c1 --- /dev/null +++ b/api/jwt/jwt_kubeconfig.go @@ -0,0 +1,26 @@ +package jwt + +import ( + portainer "github.com/portainer/portainer/api" + "time" +) + +// GenerateTokenForKubeconfig generates a new JWT token for Kubeconfig +func (service *Service) GenerateTokenForKubeconfig(data *portainer.TokenData) (string, error) { + settings, err := service.dataStore.Settings().Settings() + if err != nil { + return "", err + } + + expiryDuration, err := time.ParseDuration(settings.KubeconfigExpiry) + if err != nil { + return "", err + } + + expiryAt := time.Now().Add(expiryDuration).Unix() + if expiryDuration == time.Duration(0) { + expiryAt = 0 + } + + return service.generateSignedToken(data, expiryAt) +} diff --git a/api/jwt/jwt_kubeconfig_test.go b/api/jwt/jwt_kubeconfig_test.go new file mode 100644 index 000000000..b8269b4ca --- /dev/null +++ b/api/jwt/jwt_kubeconfig_test.go @@ -0,0 +1,81 @@ +package jwt + +import ( + "github.com/dgrijalva/jwt-go" + portainer "github.com/portainer/portainer/api" + i "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestService_GenerateTokenForKubeconfig(t *testing.T) { + type fields struct { + userSessionTimeout string + dataStore portainer.DataStore + } + + type args struct { + data *portainer.TokenData + } + + mySettings := &portainer.Settings{ + KubeconfigExpiry: "0", + } + + myFields := fields{ + userSessionTimeout: "24h", + dataStore: i.NewDatastore(i.WithSettings(mySettings)), + } + + myTokenData := &portainer.TokenData{ + Username: "Joe", + ID: 1, + Role: 1, + } + + myArgs := args{ + data: myTokenData, + } + + tests := []struct { + name string + fields fields + args args + wantExpiresAt int64 + wantErr bool + }{ + { + name: "kubeconfig no expiry", + fields: myFields, + args: myArgs, + wantExpiresAt: 0, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service, err := NewService(tt.fields.userSessionTimeout, tt.fields.dataStore) + assert.NoError(t, err, "failed to create a copy of service") + + got, err := service.GenerateTokenForKubeconfig(tt.args.data) + if (err != nil) != tt.wantErr { + t.Errorf("GenerateTokenForKubeconfig() error = %v, wantErr %v", err, tt.wantErr) + return + } + + parsedToken, err := jwt.ParseWithClaims(got, &claims{}, func(token *jwt.Token) (interface{}, error) { + return service.secret, nil + }) + assert.NoError(t, err, "failed to parse generated token") + + tokenClaims, ok := parsedToken.Claims.(*claims) + assert.Equal(t, true, ok, "failed to claims out of generated ticket") + + assert.Equal(t, myTokenData.Username, tokenClaims.Username) + assert.Equal(t, int(myTokenData.ID), tokenClaims.UserID) + assert.Equal(t, int(myTokenData.Role), tokenClaims.Role) + assert.Equal(t, tt.wantExpiresAt, tokenClaims.ExpiresAt) + }) + } +} \ No newline at end of file diff --git a/api/jwt/jwt_test.go b/api/jwt/jwt_test.go index ce70f6308..2a18783e4 100644 --- a/api/jwt/jwt_test.go +++ b/api/jwt/jwt_test.go @@ -10,7 +10,7 @@ import ( ) func TestGenerateSignedToken(t *testing.T) { - svc, err := NewService("24h") + svc, err := NewService("24h", nil) assert.NoError(t, err, "failed to create a copy of service") token := &portainer.TokenData{ @@ -18,9 +18,9 @@ func TestGenerateSignedToken(t *testing.T) { ID: 1, Role: 1, } - expirtationTime := time.Now().Add(1 * time.Hour) + expiresAt := time.Now().Add(1 * time.Hour).Unix() - generatedToken, err := svc.generateSignedToken(token, &expirtationTime) + generatedToken, err := svc.generateSignedToken(token, expiresAt) assert.NoError(t, err, "failed to generate a signed token") parsedToken, err := jwt.ParseWithClaims(generatedToken, &claims{}, func(token *jwt.Token) (interface{}, error) { @@ -34,5 +34,5 @@ func TestGenerateSignedToken(t *testing.T) { assert.Equal(t, token.Username, tokenClaims.Username) assert.Equal(t, int(token.ID), tokenClaims.UserID) assert.Equal(t, int(token.Role), tokenClaims.Role) - assert.Equal(t, expirtationTime.Unix(), tokenClaims.ExpiresAt) + assert.Equal(t, expiresAt, tokenClaims.ExpiresAt) } diff --git a/api/portainer.go b/api/portainer.go index 2d82edf3a..71ff14d70 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -689,6 +689,8 @@ type ( EnableEdgeComputeFeatures bool `json:"EnableEdgeComputeFeatures" example:""` // The duration of a user session UserSessionTimeout string `json:"UserSessionTimeout" example:"5m"` + // The expiry of a Kubeconfig + KubeconfigExpiry string `json:"KubeconfigExpiry" example:"24h"` // Whether telemetry is enabled EnableTelemetry bool `json:"EnableTelemetry" example:"false"` @@ -1215,6 +1217,7 @@ type ( JWTService interface { GenerateToken(data *TokenData) (string, error) GenerateTokenForOAuth(data *TokenData, expiryTime *time.Time) (string, error) + GenerateTokenForKubeconfig(data *TokenData) (string, error) ParseAndVerifyToken(token string) (*TokenData, error) SetUserSessionDuration(userSessionDuration time.Duration) } @@ -1452,6 +1455,8 @@ const ( DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json" // DefaultUserSessionTimeout represents the default timeout after which the user session is cleared DefaultUserSessionTimeout = "8h" + // DefaultUserSessionTimeout represents the default timeout after which the user session is cleared + DefaultKubeconfigExpiry = "0" ) const ( diff --git a/app/portainer/models/settings.js b/app/portainer/models/settings.js index 0be1a8c69..58bc3c8b1 100644 --- a/app/portainer/models/settings.js +++ b/app/portainer/models/settings.js @@ -10,6 +10,7 @@ export function SettingsViewModel(data) { this.EnableEdgeComputeFeatures = data.EnableEdgeComputeFeatures; this.UserSessionTimeout = data.UserSessionTimeout; this.EnableTelemetry = data.EnableTelemetry; + this.KubeconfigExpiry = data.KubeconfigExpiry; } export function PublicSettingsViewModel(settings) { diff --git a/app/portainer/views/settings/settings.html b/app/portainer/views/settings/settings.html index 1f8737e8c..de3bbe41e 100644 --- a/app/portainer/views/settings/settings.html +++ b/app/portainer/views/settings/settings.html @@ -118,6 +118,24 @@ + +