mirror of https://github.com/portainer/portainer
feature(kubeconfig): Do not invalidate kubeconfig upon Portainer restarting [EE-1854] (#5905)
parent
22b72fb6e3
commit
048613a0c5
|
@ -12,6 +12,7 @@ import (
|
||||||
func hideFields(settings *portainer.Settings) {
|
func hideFields(settings *portainer.Settings) {
|
||||||
settings.LDAPSettings.Password = ""
|
settings.LDAPSettings.Password = ""
|
||||||
settings.OAuthSettings.ClientSecret = ""
|
settings.OAuthSettings.ClientSecret = ""
|
||||||
|
settings.OAuthSettings.KubeSecretKey = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handler is the HTTP handler used to handle settings operations.
|
// Handler is the HTTP handler used to handle settings operations.
|
||||||
|
|
|
@ -148,8 +148,13 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) *
|
||||||
if clientSecret == "" {
|
if clientSecret == "" {
|
||||||
clientSecret = settings.OAuthSettings.ClientSecret
|
clientSecret = settings.OAuthSettings.ClientSecret
|
||||||
}
|
}
|
||||||
|
kubeSecret := payload.OAuthSettings.KubeSecretKey
|
||||||
|
if kubeSecret == nil {
|
||||||
|
kubeSecret = settings.OAuthSettings.KubeSecretKey
|
||||||
|
}
|
||||||
settings.OAuthSettings = *payload.OAuthSettings
|
settings.OAuthSettings = *payload.OAuthSettings
|
||||||
settings.OAuthSettings.ClientSecret = clientSecret
|
settings.OAuthSettings.ClientSecret = clientSecret
|
||||||
|
settings.OAuthSettings.KubeSecretKey = kubeSecret
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.EnableEdgeComputeFeatures != nil {
|
if payload.EnableEdgeComputeFeatures != nil {
|
||||||
|
|
|
@ -2,19 +2,20 @@ package jwt
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
|
||||||
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dgrijalva/jwt-go"
|
"github.com/dgrijalva/jwt-go"
|
||||||
"github.com/gorilla/securecookie"
|
"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.
|
// Service represents a service for managing JWT tokens.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
secret []byte
|
secrets map[scope][]byte
|
||||||
userSessionTimeout time.Duration
|
userSessionTimeout time.Duration
|
||||||
dataStore portainer.DataStore
|
dataStore portainer.DataStore
|
||||||
}
|
}
|
||||||
|
@ -23,6 +24,7 @@ type claims struct {
|
||||||
UserID int `json:"id"`
|
UserID int `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Role int `json:"role"`
|
Role int `json:"role"`
|
||||||
|
Scope scope `json:"scope"`
|
||||||
jwt.StandardClaims
|
jwt.StandardClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -31,6 +33,11 @@ var (
|
||||||
errInvalidJWTToken = errors.New("Invalid JWT token")
|
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.
|
// 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) {
|
func NewService(userSessionDuration string, dataStore portainer.DataStore) (*Service, error) {
|
||||||
userSessionTimeout, err := time.ParseDuration(userSessionDuration)
|
userSessionTimeout, err := time.ParseDuration(userSessionDuration)
|
||||||
|
@ -43,73 +50,122 @@ func NewService(userSessionDuration string, dataStore portainer.DataStore) (*Ser
|
||||||
return nil, errSecretGeneration
|
return nil, errSecretGeneration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kubeSecret, err := getOrCreateKubeSecret(dataStore)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
service := &Service{
|
service := &Service{
|
||||||
secret,
|
map[scope][]byte{
|
||||||
|
defaultScope: secret,
|
||||||
|
kubeConfigScope: kubeSecret,
|
||||||
|
},
|
||||||
userSessionTimeout,
|
userSessionTimeout,
|
||||||
dataStore,
|
dataStore,
|
||||||
}
|
}
|
||||||
return service, nil
|
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()
|
return time.Now().Add(service.userSessionTimeout).Unix()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateToken generates a new JWT token.
|
// GenerateToken generates a new JWT token.
|
||||||
func (service *Service) GenerateToken(data *portainer.TokenData) (string, error) {
|
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
|
// GenerateTokenForOAuth generates a new JWT token for OAuth login
|
||||||
// token expiry time from the OAuth provider is considered
|
// token expiry time response from OAuth provider is considered
|
||||||
func (service *Service) GenerateTokenForOAuth(data *portainer.TokenData, expiryTime *time.Time) (string, error) {
|
func (service *Service) GenerateTokenForOAuth(data *portainer.TokenData, expiryTime *time.Time) (string, error) {
|
||||||
expireAt := service.defaultExpireAt()
|
expireAt := service.defaultExpireAt()
|
||||||
if expiryTime != nil && !expiryTime.IsZero() {
|
if expiryTime != nil && !expiryTime.IsZero() {
|
||||||
expireAt = expiryTime.Unix()
|
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.
|
// 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) {
|
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) {
|
parsedToken, err := jwt.ParseWithClaims(token, &claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
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 nil, msg
|
||||||
}
|
}
|
||||||
return service.secret, nil
|
return secret, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if err == nil && parsedToken != nil {
|
if err == nil && parsedToken != nil {
|
||||||
if cl, ok := parsedToken.Claims.(*claims); ok && parsedToken.Valid {
|
if cl, ok := parsedToken.Claims.(*claims); ok && parsedToken.Valid {
|
||||||
tokenData := &portainer.TokenData{
|
return &portainer.TokenData{
|
||||||
ID: portainer.UserID(cl.UserID),
|
ID: portainer.UserID(cl.UserID),
|
||||||
Username: cl.Username,
|
Username: cl.Username,
|
||||||
Role: portainer.UserRole(cl.Role),
|
Role: portainer.UserRole(cl.Role),
|
||||||
}
|
}, nil
|
||||||
return tokenData, nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, errInvalidJWTToken
|
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
|
// SetUserSessionDuration sets the user session duration
|
||||||
func (service *Service) SetUserSessionDuration(userSessionDuration time.Duration) {
|
func (service *Service) SetUserSessionDuration(userSessionDuration time.Duration) {
|
||||||
service.userSessionTimeout = userSessionDuration
|
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{
|
cl := claims{
|
||||||
UserID: int(data.ID),
|
UserID: int(data.ID),
|
||||||
Username: data.Username,
|
Username: data.Username,
|
||||||
Role: int(data.Role),
|
Role: int(data.Role),
|
||||||
|
Scope: scope,
|
||||||
StandardClaims: jwt.StandardClaims{
|
StandardClaims: jwt.StandardClaims{
|
||||||
ExpiresAt: expiresAt,
|
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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,5 +22,5 @@ func (service *Service) GenerateTokenForKubeconfig(data *portainer.TokenData) (s
|
||||||
expiryAt = 0
|
expiryAt = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
return service.generateSignedToken(data, expiryAt)
|
return service.generateSignedToken(data, expiryAt, kubeConfigScope)
|
||||||
}
|
}
|
||||||
|
|
|
@ -66,7 +66,7 @@ func TestService_GenerateTokenForKubeconfig(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
parsedToken, err := jwt.ParseWithClaims(got, &claims{}, func(token *jwt.Token) (interface{}, error) {
|
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")
|
assert.NoError(t, err, "failed to parse generated token")
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package jwt
|
package jwt
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
i "github.com/portainer/portainer/api/internal/testhelpers"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -10,7 +11,8 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGenerateSignedToken(t *testing.T) {
|
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")
|
assert.NoError(t, err, "failed to create a copy of service")
|
||||||
|
|
||||||
token := &portainer.TokenData{
|
token := &portainer.TokenData{
|
||||||
|
@ -20,11 +22,11 @@ func TestGenerateSignedToken(t *testing.T) {
|
||||||
}
|
}
|
||||||
expiresAt := time.Now().Add(1 * time.Hour).Unix()
|
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")
|
assert.NoError(t, err, "failed to generate a signed token")
|
||||||
|
|
||||||
parsedToken, err := jwt.ParseWithClaims(generatedToken, &claims{}, func(token *jwt.Token) (interface{}, error) {
|
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")
|
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, int(token.Role), tokenClaims.Role)
|
||||||
assert.Equal(t, expiresAt, tokenClaims.ExpiresAt)
|
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())
|
||||||
|
}
|
||||||
|
|
|
@ -548,6 +548,7 @@ type (
|
||||||
DefaultTeamID TeamID `json:"DefaultTeamID"`
|
DefaultTeamID TeamID `json:"DefaultTeamID"`
|
||||||
SSO bool `json:"SSO"`
|
SSO bool `json:"SSO"`
|
||||||
LogoutURI string `json:"LogoutURI"`
|
LogoutURI string `json:"LogoutURI"`
|
||||||
|
KubeSecretKey []byte `json:"KubeSecretKey"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pair defines a key/value string pair
|
// Pair defines a key/value string pair
|
||||||
|
|
Loading…
Reference in New Issue