github.com/kiali/kiali@v1.84.0/business/authentication/session_persistor.go (about)

     1  package authentication
     2  
     3  import (
     4  	"crypto/aes"
     5  	"crypto/cipher"
     6  	"encoding/base64"
     7  	"encoding/json"
     8  	"errors"
     9  	"fmt"
    10  	"net/http"
    11  	"strconv"
    12  	"strings"
    13  	"time"
    14  
    15  	"github.com/kiali/kiali/config"
    16  	"github.com/kiali/kiali/log"
    17  	"github.com/kiali/kiali/util"
    18  	"github.com/kiali/kiali/util/httputil"
    19  )
    20  
    21  // SessionCookieMaxSize is the maximum size of session cookies. This is 3.5K.
    22  // Major browsers limit cookie size to 4K, but this includes
    23  // metadata like expiration date, the cookie name, etc. So
    24  // use 3.5K for cookie data and leave 0.5K for metadata.
    25  const SessionCookieMaxSize = 3584
    26  
    27  type SessionPersistor interface {
    28  	CreateSession(r *http.Request, w http.ResponseWriter, strategy string, expiresOn time.Time, payload interface{}) error
    29  	ReadSession(r *http.Request, w http.ResponseWriter, payload interface{}) (sData *sessionData, err error)
    30  	TerminateSession(r *http.Request, w http.ResponseWriter)
    31  }
    32  
    33  const (
    34  	AESSessionCookieName       = config.TokenCookieName + "-aes"
    35  	AESSessionChunksCookieName = config.TokenCookieName + "-chunks"
    36  )
    37  
    38  func NewCookieSessionPersistor(conf *config.Config) *cookieSessionPersistor {
    39  	return &cookieSessionPersistor{conf: conf}
    40  }
    41  
    42  // CookieSessionPersistor is a session storage based on browser cookies. Session
    43  // persistence is achieved by storing all session data in browser cookies. Only
    44  // client-side storage is used and no back-end storage is needed.
    45  // Browser cookies have size constraints and the workaround for large session data/payload
    46  // is using multiple cookies. There is still a (browser dependant) limit on the
    47  // number of cookies that a website can set but we haven't heard of a user
    48  // facing problems because of reaching this limit.
    49  type cookieSessionPersistor struct {
    50  	conf *config.Config
    51  }
    52  
    53  type sessionData struct {
    54  	Strategy  string    `json:"strategy"`
    55  	ExpiresOn time.Time `json:"expiresOn"`
    56  	Payload   string    `json:"payload,omitempty"`
    57  }
    58  
    59  // CreateSession starts a user session using HTTP Cookies for persistance across HTTP requests.
    60  // For improved security, the data of the session is encrypted using the AES-GCM algorithm and
    61  // the encrypted data is what is sent in cookies. The strategy, expiresOn and payload arguments
    62  // are all required.
    63  func (p cookieSessionPersistor) CreateSession(r *http.Request, w http.ResponseWriter, strategy string, expiresOn time.Time, payload interface{}) error {
    64  	// Validate that there is a payload and a strategy. The strategy is required just in case Kiali is reconfigured with a
    65  	// different strategy and drop any stale session. The payload is required because it does not make sense to start a session
    66  	// if there is no data to persist.
    67  	if payload == nil || len(strategy) == 0 {
    68  		return errors.New("a session cannot be created without strategy, or with a nil payload")
    69  	}
    70  
    71  	// Reject expiration time that is already in the past.
    72  	if !util.Clock.Now().Before(expiresOn) {
    73  		return errors.New("the expiration time of a session cannot be in the past")
    74  	}
    75  
    76  	// Serialize the payload. The resulting string will be re-serialized along some metadata.
    77  	// It may not sound very efficient to serialize twice (the sessionData struct may declare
    78  	//  its Payload field as interface{}). However, this allows de-serialization
    79  	// to the original type in the ReadSession function, rather than manually parsing a generic map[string]interface{}.
    80  	// Read more in the ReadSession function.
    81  	payloadMarshalled, err := json.Marshal(payload)
    82  	if err != nil {
    83  		return fmt.Errorf("error when creating the session - failed to marshal payload: %w", err)
    84  	}
    85  
    86  	// Add some metadata to the session and serialize this structure. The resulting string
    87  	// is what will be encrypted and stored in cookies.
    88  	sData := sessionData{
    89  		Strategy:  strategy,
    90  		ExpiresOn: expiresOn,
    91  		Payload:   string(payloadMarshalled),
    92  	}
    93  
    94  	sDataJson, err := json.Marshal(sData)
    95  	if err != nil {
    96  		return fmt.Errorf("error when creating the session - failed to marshal JSON: %w", err)
    97  	}
    98  
    99  	// The sDataJson string holds the session data that we want to persist.
   100  	// It's time to encrypt this data which will result in an illegible sequence of bytes which are then
   101  	// encoded to base64 get a string that is suitable to store in browser cookies.
   102  	block, err := aes.NewCipher([]byte(getSigningKey(p.conf)))
   103  	if err != nil {
   104  		return fmt.Errorf("error when creating the session - failed to create cipher: %w", err)
   105  	}
   106  
   107  	aesGcm, err := cipher.NewGCM(block)
   108  	if err != nil {
   109  		return fmt.Errorf("error when creating credentials - failed to create gcm: %w", err)
   110  	}
   111  
   112  	aesGcmNonce, err := util.CryptoRandomBytes(aesGcm.NonceSize())
   113  	if err != nil {
   114  		return fmt.Errorf("error when creating credentials - failed to generate random bytes: %w", err)
   115  	}
   116  
   117  	cipherSessionData := aesGcm.Seal(aesGcmNonce, aesGcmNonce, sDataJson, nil)
   118  	base64SessionData := base64.StdEncoding.EncodeToString(cipherSessionData)
   119  
   120  	// The base64SessionData holds what we want to store in browser cookies.
   121  	// It's time to set/send the browser cookies to persist the session.
   122  
   123  	// If the resulting session data is large, it may not fit in one cookie. So, the resulting
   124  	// session data is broken in chunks and multiple cookies are used, as is needed.
   125  	secureFlag := p.conf.IsServerHTTPS() || strings.HasPrefix(httputil.GuessKialiURL(p.conf, r), "https:")
   126  
   127  	sessionDataChunks := chunkString(base64SessionData, SessionCookieMaxSize)
   128  	for i, chunk := range sessionDataChunks {
   129  		var cookieName string
   130  		if i == 0 {
   131  			// Set a cookie with the regular cookie name with the first chunk of session data.
   132  			// Notice that an "-aes" suffix is being used in the cookie names. This is for backwards compatibility and
   133  			// is/was meant to be able to differentiate between a session using cookies holding encrypted data, and the older
   134  			// less secure sessions using cookies holding JWTs.
   135  			cookieName = AESSessionCookieName
   136  		} else {
   137  			// If there are more chunks of session data (usually because of larger tokens from the IdP),
   138  			// store the remainder data to numbered cookies.
   139  			cookieName = fmt.Sprintf("%s-aes-%d", config.TokenCookieName, i)
   140  		}
   141  
   142  		authCookie := http.Cookie{
   143  			Name:     cookieName,
   144  			Value:    chunk,
   145  			Expires:  expiresOn,
   146  			HttpOnly: true,
   147  			Secure:   secureFlag,
   148  			Path:     p.conf.Server.WebRoot,
   149  			SameSite: http.SameSiteStrictMode,
   150  		}
   151  		http.SetCookie(w, &authCookie)
   152  	}
   153  
   154  	if len(sessionDataChunks) > 1 {
   155  		// Set a cookie with the number of chunks of the session data.
   156  		// This is to protect against reading spurious chunks of data if there is
   157  		// any failure when killing the session or logging out.
   158  		chunksCookie := http.Cookie{
   159  			Name:     AESSessionChunksCookieName,
   160  			Value:    strconv.Itoa(len(sessionDataChunks)),
   161  			Expires:  expiresOn,
   162  			HttpOnly: true,
   163  			Secure:   secureFlag,
   164  			Path:     p.conf.Server.WebRoot,
   165  			SameSite: http.SameSiteStrictMode,
   166  		}
   167  		http.SetCookie(w, &chunksCookie)
   168  	}
   169  
   170  	return nil
   171  }
   172  
   173  // ReadSession restores (decrypts) and returns the data that was persisted when using the CreateSession function.
   174  // If a payload is provided, the original data is parsed and stored in the payload argument. As part of restoring
   175  // the session, validation of expiration time is performed and no data is returned assuming the session is stale.
   176  // Also, it is verified that the currently configured authentication strategy is the same as when the session was
   177  // created.
   178  func (p cookieSessionPersistor) ReadSession(r *http.Request, w http.ResponseWriter, payload interface{}) (*sessionData, error) {
   179  	// This CookieSessionPersistor only deals with sessions using cookies holding encrypted data.
   180  	// Thus, presence for a cookie with the "-aes" suffix is checked and it's assumed no active session
   181  	// if such cookie is not found in the request.
   182  	authCookie, err := r.Cookie(AESSessionCookieName)
   183  	if err != nil {
   184  		if err == http.ErrNoCookie {
   185  			log.Tracef("The AES cookie is missing.")
   186  			return nil, nil
   187  		}
   188  		return nil, fmt.Errorf("unable to read the -aes cookie: %w", err)
   189  	}
   190  
   191  	// Initially, take the value of the "-aes" cookie as the session data.
   192  	// This helps a smoother transition from a previous version of Kiali where
   193  	// no support for multiple cookies existed and no "-chunks" cookie was set.
   194  	// With this, we tolerate the absence of the "-chunks" cookie to not force
   195  	// users to re-authenticate if somebody was already logged into Kiali.
   196  	base64SessionData := authCookie.Value
   197  
   198  	// Check if session data is broken in chunks. If it is, read all chunks
   199  	numChunksCookie, chunksCookieErr := r.Cookie(config.TokenCookieName + "-chunks")
   200  	if chunksCookieErr == nil {
   201  		numChunks, convErr := strconv.Atoi(numChunksCookie.Value)
   202  		if convErr != nil {
   203  			return nil, fmt.Errorf("unable to read the chunks cookie: %w", convErr)
   204  		}
   205  
   206  		// It's known that major browsers have a limit of 180 cookies per domain.
   207  		if numChunks <= 0 || numChunks > 180 {
   208  			return nil, fmt.Errorf("number of session cookies is %d, but limit is 1 through 180", numChunks)
   209  		}
   210  
   211  		// Read session data chunks and save into a buffer
   212  		var sessionDataBuffer strings.Builder
   213  		sessionDataBuffer.Grow(numChunks * SessionCookieMaxSize)
   214  		sessionDataBuffer.WriteString(base64SessionData)
   215  
   216  		for i := 1; i < numChunks; i++ {
   217  			cookieName := fmt.Sprintf("%s-aes-%d", config.TokenCookieName, i)
   218  			authChunkCookie, chunkErr := r.Cookie(cookieName)
   219  			if chunkErr != nil {
   220  				return nil, fmt.Errorf("failed to read session cookie chunk number %d: %w", i, chunkErr)
   221  			}
   222  
   223  			sessionDataBuffer.WriteString(authChunkCookie.Value)
   224  		}
   225  
   226  		// Get the concatenated session data
   227  		base64SessionData = sessionDataBuffer.String()
   228  	} else if chunksCookieErr != http.ErrNoCookie {
   229  		// Tolerate a "no cookie" error, but if error is something else, throw up the error.
   230  		return nil, fmt.Errorf("failed to read the chunks cookie: %w", chunksCookieErr)
   231  	}
   232  
   233  	// Persisted data has been read, but it's base64 encoded and it's also encrypted (per
   234  	// the process in CreateSession function). Reverse the encoding and, then, decrypt the data.
   235  	cipherSessionData, err := base64.StdEncoding.DecodeString(base64SessionData)
   236  	if err != nil {
   237  		// Older cookie specs don't allow "=", so it may get trimmed out.  If the std encoding
   238  		// doesn't work, try raw encoding (with no padding).  If it still fails, error out
   239  		cipherSessionData, err = base64.RawStdEncoding.DecodeString(base64SessionData)
   240  		if err != nil {
   241  			return nil, fmt.Errorf("unable to decode session data: %w", err)
   242  		}
   243  	}
   244  
   245  	block, err := aes.NewCipher([]byte(getSigningKey(p.conf)))
   246  	if err != nil {
   247  		return nil, fmt.Errorf("error when restoring the session - failed to create the cipher: %w", err)
   248  	}
   249  
   250  	aesGCM, err := cipher.NewGCM(block)
   251  	if err != nil {
   252  		return nil, fmt.Errorf("error when restoring the session - failed to create gcm: %w", err)
   253  	}
   254  
   255  	nonceSize := aesGCM.NonceSize()
   256  	nonce, cipherSessionData := cipherSessionData[:nonceSize], cipherSessionData[nonceSize:]
   257  
   258  	sessionDataJson, err := aesGCM.Open(nil, nonce, cipherSessionData, nil)
   259  	if err != nil {
   260  		return nil, fmt.Errorf("error when restoring the session - failed to decrypt: %w", err)
   261  	}
   262  
   263  	// sessionDataJson is holding the decrypted data as a string. This should be a JSON document. Let's parse it.
   264  	var sData sessionData
   265  	err = json.Unmarshal(sessionDataJson, &sData)
   266  	if err != nil {
   267  		return nil, fmt.Errorf("error when restoring the session - failed to parse the session data: %w", err)
   268  	}
   269  
   270  	// Check that the currently configured strategy matches the strategy set in the session.
   271  	// This is to prevent taking a session as valid if somebody re-configured Kiali with a different auth strategy.
   272  	if sData.Strategy != p.conf.Auth.Strategy {
   273  		log.Tracef("Session is invalid because it was created with authentication strategy %s, but current authentication strategy is %s", sData.Strategy, p.conf.Auth.Strategy)
   274  		p.TerminateSession(r, w) // Kill the spurious session
   275  
   276  		return nil, nil
   277  	}
   278  
   279  	// Check that the session has not expired.
   280  	// This is just a sanity check, because browser cookies are set to expire at this date and the browser
   281  	// shouldn't send expired cookies.
   282  	if !util.Clock.Now().Before(sData.ExpiresOn) {
   283  		log.Tracef("Session is invalid because it expired on %s", sData.ExpiresOn.Format(time.RFC822))
   284  		p.TerminateSession(r, w) // Clean the expired session
   285  
   286  		return nil, nil
   287  	}
   288  
   289  	// The Payload field of the parsed JSON contains yet another JSON document. This is where we see the advantage
   290  	// of the double serialization of the payload. Here in ReadSession we are receiving a payload argument. If the caller
   291  	// passes an object with the original type of the payload that was passed to CreateSession, we can let the json
   292  	// library to parse and set the data in the payload variable/argument, removing the need to deal with a
   293  	// Payload typed as map[string]interface{}.
   294  	if payload != nil {
   295  		payloadErr := json.Unmarshal([]byte(sData.Payload), payload)
   296  		if payloadErr != nil {
   297  			return nil, fmt.Errorf("error when restoring the session - failed to parse the session payload: %w", payloadErr)
   298  		}
   299  	}
   300  
   301  	return &sData, nil
   302  }
   303  
   304  // TerminateSession destroys any persisted data of a session created by the CreateSession function.
   305  // The session is terminated unconditionally (that is, there is no validation of the session), allowing
   306  // clearing any stale cookies/session.
   307  func (p cookieSessionPersistor) TerminateSession(r *http.Request, w http.ResponseWriter) {
   308  	secureFlag := p.conf.IsServerHTTPS() || strings.HasPrefix(httputil.GuessKialiURL(p.conf, r), "https:")
   309  
   310  	var cookiesToDrop []string
   311  
   312  	numChunksCookie, chunksCookieErr := r.Cookie(config.TokenCookieName + "-chunks")
   313  	if chunksCookieErr == nil {
   314  		numChunks, convErr := strconv.Atoi(numChunksCookie.Value)
   315  		if convErr == nil && numChunks > 1 && numChunks <= 180 {
   316  			cookiesToDrop = make([]string, 0, numChunks+2)
   317  			for i := 1; i < numChunks; i++ {
   318  				cookiesToDrop = append(cookiesToDrop, fmt.Sprintf("%s-aes-%d", config.TokenCookieName, i))
   319  			}
   320  		} else {
   321  			cookiesToDrop = make([]string, 0, 3)
   322  		}
   323  	} else {
   324  		cookiesToDrop = make([]string, 0, 3)
   325  	}
   326  
   327  	cookiesToDrop = append(cookiesToDrop, config.TokenCookieName)
   328  	cookiesToDrop = append(cookiesToDrop, config.TokenCookieName+"-aes")
   329  	cookiesToDrop = append(cookiesToDrop, config.TokenCookieName+"-chunks")
   330  
   331  	for _, cookieName := range cookiesToDrop {
   332  		_, err := r.Cookie(cookieName)
   333  		if err != http.ErrNoCookie {
   334  			tokenCookie := http.Cookie{
   335  				Name:     cookieName,
   336  				Value:    "",
   337  				Expires:  time.Unix(0, 0),
   338  				HttpOnly: true,
   339  				Secure:   secureFlag,
   340  				MaxAge:   -1,
   341  				Path:     p.conf.Server.WebRoot,
   342  				SameSite: http.SameSiteStrictMode,
   343  			}
   344  			http.SetCookie(w, &tokenCookie)
   345  		}
   346  	}
   347  }
   348  
   349  // Acknowledgement to rinat.io user of SO.
   350  // Taken from https://stackoverflow.com/a/48479355 with a few modifications
   351  func chunkString(s string, chunkSize int) []string {
   352  	if len(s) <= chunkSize {
   353  		return []string{s}
   354  	}
   355  
   356  	numChunks := len(s)/chunkSize + 1
   357  	chunks := make([]string, 0, numChunks)
   358  	runes := []rune(s)
   359  
   360  	for i := 0; i < len(runes); i += chunkSize {
   361  		nn := i + chunkSize
   362  		if nn > len(runes) {
   363  			nn = len(runes)
   364  		}
   365  		chunks = append(chunks, string(runes[i:nn]))
   366  	}
   367  	return chunks
   368  }