diff --git a/api/http/handler/settings/handler.go b/api/http/handler/settings/handler.go index cbb159fca..a0d5f1db7 100644 --- a/api/http/handler/settings/handler.go +++ b/api/http/handler/settings/handler.go @@ -12,6 +12,7 @@ import ( func hideFields(settings *portainer.Settings) { settings.LDAPSettings.Password = "" settings.OAuthSettings.ClientSecret = "" + settings.OAuthSettings.KubeSecretKey = nil } // Handler is the HTTP handler used to handle settings operations. diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go index 1e52fccfb..728e38fa6 100644 --- a/api/http/handler/settings/settings_update.go +++ b/api/http/handler/settings/settings_update.go @@ -148,8 +148,13 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) * if clientSecret == "" { clientSecret = settings.OAuthSettings.ClientSecret } + kubeSecret := payload.OAuthSettings.KubeSecretKey + if kubeSecret == nil { + kubeSecret = settings.OAuthSettings.KubeSecretKey + } settings.OAuthSettings = *payload.OAuthSettings settings.OAuthSettings.ClientSecret = clientSecret + settings.OAuthSettings.KubeSecretKey = kubeSecret } if payload.EnableEdgeComputeFeatures != nil { diff --git a/api/jwt/jwt.go b/api/jwt/jwt.go index 5786caf85..3899dd613 100644 --- a/api/jwt/jwt.go +++ b/api/jwt/jwt.go @@ -2,19 +2,20 @@ package jwt import ( "errors" - - portainer "github.com/portainer/portainer/api" - "fmt" "time" "github.com/dgrijalva/jwt-go" "github.com/gorilla/securecookie" + portainer "github.com/portainer/portainer/api" ) +// scope represents JWT scopes that are supported in JWT claims. +type scope string + // Service represents a service for managing JWT tokens. type Service struct { - secret []byte + secrets map[scope][]byte userSessionTimeout time.Duration dataStore portainer.DataStore } @@ -23,6 +24,7 @@ type claims struct { UserID int `json:"id"` Username string `json:"username"` Role int `json:"role"` + Scope scope `json:"scope"` jwt.StandardClaims } @@ -31,6 +33,11 @@ var ( errInvalidJWTToken = errors.New("Invalid JWT token") ) +const ( + defaultScope = scope("default") + kubeConfigScope = scope("kubeconfig") +) + // NewService initializes a new service. It will generate a random key that will be used to sign JWT tokens. func NewService(userSessionDuration string, dataStore portainer.DataStore) (*Service, error) { userSessionTimeout, err := time.ParseDuration(userSessionDuration) @@ -43,73 +50,122 @@ func NewService(userSessionDuration string, dataStore portainer.DataStore) (*Ser return nil, errSecretGeneration } + kubeSecret, err := getOrCreateKubeSecret(dataStore) + if err != nil { + return nil, err + } + service := &Service{ - secret, + map[scope][]byte{ + defaultScope: secret, + kubeConfigScope: kubeSecret, + }, userSessionTimeout, dataStore, } return service, nil } -func (service *Service) defaultExpireAt() (int64) { +func getOrCreateKubeSecret(dataStore portainer.DataStore) ([]byte, error) { + settings, err := dataStore.Settings().Settings() + if err != nil { + return nil, err + } + + kubeSecret := settings.OAuthSettings.KubeSecretKey + if kubeSecret == nil { + kubeSecret = securecookie.GenerateRandomKey(32) + if kubeSecret == nil { + return nil, errSecretGeneration + } + settings.OAuthSettings.KubeSecretKey = kubeSecret + err = dataStore.Settings().UpdateSettings(settings) + if err != nil { + return nil, err + } + } + return kubeSecret, 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, service.defaultExpireAt()) + return service.generateSignedToken(data, service.defaultExpireAt(), defaultScope) } -// GenerateTokenForOAuth generates a new JWT for OAuth login -// token expiry time from the OAuth provider is considered +// 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) + return service.generateSignedToken(data, expireAt, defaultScope) } // ParseAndVerifyToken parses a JWT token and verify its validity. It returns an error if token is invalid. func (service *Service) ParseAndVerifyToken(token string) (*portainer.TokenData, error) { + scope := parseScope(token) + secret := service.secrets[scope] parsedToken, err := jwt.ParseWithClaims(token, &claims{}, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - msg := fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) + msg := fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) return nil, msg } - return service.secret, nil + return secret, nil }) + if err == nil && parsedToken != nil { if cl, ok := parsedToken.Claims.(*claims); ok && parsedToken.Valid { - tokenData := &portainer.TokenData{ + return &portainer.TokenData{ ID: portainer.UserID(cl.UserID), Username: cl.Username, Role: portainer.UserRole(cl.Role), - } - return tokenData, nil + }, nil } } - return nil, errInvalidJWTToken } +// parse a JWT token, fallback to defaultScope if no scope is present in the JWT +func parseScope(token string) scope { + unverifiedToken, _, _ := new(jwt.Parser).ParseUnverified(token, &claims{}) + if unverifiedToken != nil { + if cl, ok := unverifiedToken.Claims.(*claims); ok { + if cl.Scope == kubeConfigScope { + return kubeConfigScope + } + } + } + return defaultScope +} + // SetUserSessionDuration sets the user session duration func (service *Service) SetUserSessionDuration(userSessionDuration time.Duration) { service.userSessionTimeout = userSessionDuration } -func (service *Service) generateSignedToken(data *portainer.TokenData, expiresAt int64) (string, error) { +func (service *Service) generateSignedToken(data *portainer.TokenData, expiresAt int64, scope scope) (string, error) { + secret, found := service.secrets[scope] + if !found { + return "", fmt.Errorf("invalid scope: %v", scope) + } + cl := claims{ UserID: int(data.ID), Username: data.Username, Role: int(data.Role), + Scope: scope, StandardClaims: jwt.StandardClaims{ ExpiresAt: expiresAt, }, } - token := jwt.NewWithClaims(jwt.SigningMethodHS256, cl) - signedToken, err := token.SignedString(service.secret) + token := jwt.NewWithClaims(jwt.SigningMethodHS256, cl) + signedToken, err := token.SignedString(secret) if err != nil { return "", err } diff --git a/api/jwt/jwt_kubeconfig.go b/api/jwt/jwt_kubeconfig.go index 544a481c1..6e099d7f5 100644 --- a/api/jwt/jwt_kubeconfig.go +++ b/api/jwt/jwt_kubeconfig.go @@ -22,5 +22,5 @@ func (service *Service) GenerateTokenForKubeconfig(data *portainer.TokenData) (s expiryAt = 0 } - return service.generateSignedToken(data, expiryAt) + return service.generateSignedToken(data, expiryAt, kubeConfigScope) } diff --git a/api/jwt/jwt_kubeconfig_test.go b/api/jwt/jwt_kubeconfig_test.go index 32289d0f3..1092a7dfc 100644 --- a/api/jwt/jwt_kubeconfig_test.go +++ b/api/jwt/jwt_kubeconfig_test.go @@ -66,7 +66,7 @@ func TestService_GenerateTokenForKubeconfig(t *testing.T) { } parsedToken, err := jwt.ParseWithClaims(got, &claims{}, func(token *jwt.Token) (interface{}, error) { - return service.secret, nil + return service.secrets[kubeConfigScope], nil }) assert.NoError(t, err, "failed to parse generated token") diff --git a/api/jwt/jwt_test.go b/api/jwt/jwt_test.go index 2a18783e4..c19f50073 100644 --- a/api/jwt/jwt_test.go +++ b/api/jwt/jwt_test.go @@ -1,6 +1,7 @@ package jwt import ( + i "github.com/portainer/portainer/api/internal/testhelpers" "testing" "time" @@ -10,7 +11,8 @@ import ( ) func TestGenerateSignedToken(t *testing.T) { - svc, err := NewService("24h", nil) + dataStore := i.NewDatastore(i.WithSettingsService(&portainer.Settings{})) + svc, err := NewService("24h", dataStore) assert.NoError(t, err, "failed to create a copy of service") token := &portainer.TokenData{ @@ -20,11 +22,11 @@ func TestGenerateSignedToken(t *testing.T) { } expiresAt := time.Now().Add(1 * time.Hour).Unix() - generatedToken, err := svc.generateSignedToken(token, expiresAt) + generatedToken, err := svc.generateSignedToken(token, expiresAt, defaultScope) assert.NoError(t, err, "failed to generate a signed token") parsedToken, err := jwt.ParseWithClaims(generatedToken, &claims{}, func(token *jwt.Token) (interface{}, error) { - return svc.secret, nil + return svc.secrets[defaultScope], nil }) assert.NoError(t, err, "failed to parse generated token") @@ -36,3 +38,20 @@ func TestGenerateSignedToken(t *testing.T) { assert.Equal(t, int(token.Role), tokenClaims.Role) assert.Equal(t, expiresAt, tokenClaims.ExpiresAt) } + +func TestGenerateSignedToken_InvalidScope(t *testing.T) { + dataStore := i.NewDatastore(i.WithSettingsService(&portainer.Settings{})) + svc, err := NewService("24h", dataStore) + assert.NoError(t, err, "failed to create a copy of service") + + token := &portainer.TokenData{ + Username: "Joe", + ID: 1, + Role: 1, + } + expiresAt := time.Now().Add(1 * time.Hour).Unix() + + _, err = svc.generateSignedToken(token, expiresAt, "testing") + assert.Error(t, err) + assert.Equal(t, "invalid scope: testing", err.Error()) +} diff --git a/api/portainer.go b/api/portainer.go index 64f74c2b0..7c0b908c1 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -548,6 +548,7 @@ type ( DefaultTeamID TeamID `json:"DefaultTeamID"` SSO bool `json:"SSO"` LogoutURI string `json:"LogoutURI"` + KubeSecretKey []byte `json:"KubeSecretKey"` } // Pair defines a key/value string pair