You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
portainer/api/jwt/jwt.go

215 lines
5.7 KiB

package jwt
import (
"errors"
"fmt"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/apikey"
"github.com/portainer/portainer/api/dataservices"
"github.com/gofrs/uuid"
"github.com/golang-jwt/jwt/v4"
"github.com/rs/zerolog/log"
)
const (
year = time.Hour * 24 * 365
keyLen = 32
defaultScope = scope("default")
kubeConfigScope = scope("kubeconfig")
)
// scope represents JWT scopes that are supported in JWT claims.
type scope string
// Service represents a service for managing JWT tokens.
type Service struct {
secrets map[scope][]byte
userSessionTimeout time.Duration
dataStore dataservices.DataStore
}
type claims struct {
UserID int `json:"id"`
Username string `json:"username"`
Role int `json:"role"`
Scope scope `json:"scope"`
ForceChangePassword bool `json:"forceChangePassword"`
jwt.RegisteredClaims
}
var (
errSecretGeneration = errors.New("unable to generate secret key")
errInvalidJWTToken = errors.New("invalid JWT token")
)
// NewService initializes a new service. It will generate a random key that will be used to sign JWT tokens.
func NewService(userSessionDuration string, dataStore dataservices.DataStore) (*Service, error) {
userSessionTimeout, err := time.ParseDuration(userSessionDuration)
if err != nil {
return nil, err
}
secret := apikey.GenerateRandomKey(keyLen)
if secret == nil {
return nil, errSecretGeneration
}
kubeSecret, err := getOrCreateKubeSecret(dataStore)
if err != nil {
return nil, err
}
return &Service{
map[scope][]byte{
defaultScope: secret,
kubeConfigScope: kubeSecret,
},
userSessionTimeout,
dataStore,
}, nil
}
func getOrCreateKubeSecret(dataStore dataservices.DataStore) ([]byte, error) {
settings, err := dataStore.Settings().Settings()
if err != nil {
return nil, err
}
kubeSecret := settings.OAuthSettings.KubeSecretKey
if kubeSecret != nil {
return kubeSecret, nil
}
kubeSecret = apikey.GenerateRandomKey(keyLen)
if kubeSecret == nil {
return nil, errSecretGeneration
}
settings.OAuthSettings.KubeSecretKey = kubeSecret
if err := dataStore.Settings().UpdateSettings(settings); err != nil {
return nil, err
}
return kubeSecret, nil
}
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, time.Time, error) {
expiryTime := service.defaultExpireAt()
token, err := service.generateSignedToken(data, expiryTime, defaultScope)
return token, expiryTime, err
}
// 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, string, time.Time, error) {
scope := parseScope(token)
secret := service.secrets[scope]
parsedToken, err := jwt.ParseWithClaims(token, &claims{}, func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return secret, nil
})
if err != nil || parsedToken == nil {
return nil, "", time.Time{}, errInvalidJWTToken
}
cl, ok := parsedToken.Claims.(*claims)
if !ok || !parsedToken.Valid {
return nil, "", time.Time{}, errInvalidJWTToken
}
user, err := service.dataStore.User().Read(portainer.UserID(cl.UserID))
if err != nil || user.TokenIssueAt > cl.RegisteredClaims.IssuedAt.Unix() {
return nil, "", time.Time{}, errInvalidJWTToken
}
if cl.ExpiresAt == nil {
cl.ExpiresAt = &jwt.NumericDate{}
}
return &portainer.TokenData{
ID: portainer.UserID(cl.UserID),
Username: cl.Username,
Role: portainer.UserRole(cl.Role),
Token: token,
ForceChangePassword: cl.ForceChangePassword,
}, cl.ID, cl.ExpiresAt.Time, nil
}
// 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 {
return defaultScope
}
if cl, ok := unverifiedToken.Claims.(*claims); ok && 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 time.Time, scope scope) (string, error) {
secret, found := service.secrets[scope]
if !found {
return "", fmt.Errorf("invalid scope: %v", scope)
}
settings, err := service.dataStore.Settings().Settings()
if err != nil {
return "", fmt.Errorf("failed fetching settings from db: %w", err)
}
if settings.IsDockerDesktopExtension {
log.Info().Msg("detected docker desktop extension mode")
expiresAt = time.Now().Add(99 * year)
}
uuid, err := uuid.NewV4()
if err != nil {
return "", fmt.Errorf("unable to generate the JWT ID: %w", err)
}
cl := claims{
UserID: int(data.ID),
Username: data.Username,
Role: int(data.Role),
Scope: scope,
ForceChangePassword: data.ForceChangePassword,
RegisteredClaims: jwt.RegisteredClaims{
ID: uuid.String(),
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(expiresAt),
},
}
// If expiresAt is set to a zero value, the token should never expire
if expiresAt.IsZero() {
cl.RegisteredClaims.ExpiresAt = nil
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, cl)
return token.SignedString(secret)
}