feat(kubeconfig): Introduce the ability to change the expiry of a kubeconfig EE-1153 (#5421)

* feat(kubeconfig) EE-1153 Introduce the ability to change the expiry of a kubeconfig

* feat(kubeconfig) EE-1153 pr feedback update

* feat(kubeconfig) EE-1153 code cleanup

Co-authored-by: Simon Meng <simon.meng@portainer.io>
pull/5458/head
cong meng 2021-09-01 09:23:21 +12:00 committed by GitHub
parent c597ae96e2
commit 35013e7b6a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 221 additions and 37 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

26
api/jwt/jwt_kubeconfig.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -118,6 +118,24 @@
</div>
</div>
<!-- !edge -->
<!-- kube -->
<div class="col-sm-12 form-section-title">
Kubernetes
</div>
<div class="form-group">
<label for="edge_checkin" class="col-sm-2 control-label text-left">
Kubeconfig expiry
</label>
<div class="col-sm-10">
<select
id="kubeconfig_expiry"
class="form-control"
ng-model="settings.KubeconfigExpiry"
ng-options="opt.value as opt.key for opt in state.availableKubeconfigExpiryOptions"
></select>
</div>
</div>
<!-- ! kube -->
<!-- actions -->
<div class="form-group">
<div class="col-sm-12">

View File

@ -24,7 +24,28 @@ angular.module('portainer.app').controller('SettingsController', [
value: 30,
},
],
availableKubeconfigExpiryOptions: [
{
key: '1 day',
value: '24h',
},
{
key: '7 days',
value: `${24 * 7}h`,
},
{
key: '30 days',
value: `${24 * 30}h`,
},
{
key: '1 year',
value: `${24 * 30 * 12}h`,
},
{
key: 'No expiry',
value: '0',
},
],
backupInProgress: false,
};