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 }