001/* 002 * The contents of this file are subject to the terms of the Common Development and 003 * Distribution License (the License). You may not use this file except in compliance with the 004 * License. 005 * 006 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the 007 * specific language governing permission and limitations under the License. 008 * 009 * When distributing Covered Software, include this CDDL Header Notice in each file and include 010 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL 011 * Header, with the fields enclosed by brackets [] replaced by your own identifying 012 * information: "Portions copyright [year] [name of copyright owner]". 013 * 014 * Copyright 2014-2015 ForgeRock AS. 015 */ 016 017package org.forgerock.openig.jwt; 018 019import static java.lang.String.format; 020import static java.util.Collections.singletonList; 021import static java.util.concurrent.TimeUnit.MILLISECONDS; 022import static java.util.concurrent.TimeUnit.MINUTES; 023import static org.forgerock.http.util.Json.*; 024import static org.forgerock.openig.jwt.JwtSessionManager.MAX_SESSION_TIMEOUT; 025 026import java.io.IOException; 027import java.security.KeyPair; 028import java.util.Collection; 029import java.util.Date; 030import java.util.LinkedHashMap; 031import java.util.List; 032import java.util.Map; 033import java.util.Set; 034 035import org.forgerock.http.header.SetCookieHeader; 036import org.forgerock.http.protocol.Cookie; 037import org.forgerock.http.protocol.Request; 038import org.forgerock.http.protocol.Response; 039import org.forgerock.http.session.Session; 040import org.forgerock.json.jose.builders.EncryptedJwtBuilder; 041import org.forgerock.json.jose.builders.JwtBuilderFactory; 042import org.forgerock.json.jose.builders.JwtClaimsSetBuilder; 043import org.forgerock.json.jose.common.JwtReconstruction; 044import org.forgerock.json.jose.exceptions.JweDecryptionException; 045import org.forgerock.json.jose.jwe.EncryptedJwt; 046import org.forgerock.json.jose.jwe.EncryptionMethod; 047import org.forgerock.json.jose.jwe.JweAlgorithm; 048import org.forgerock.json.jose.jwt.JwtClaimsSet; 049import org.forgerock.openig.jwt.dirty.DirtyCollection; 050import org.forgerock.openig.jwt.dirty.DirtyListener; 051import org.forgerock.openig.jwt.dirty.DirtySet; 052import org.forgerock.openig.log.Logger; 053import org.forgerock.util.MapDecorator; 054import org.forgerock.util.Reject; 055import org.forgerock.util.time.Duration; 056import org.forgerock.util.time.TimeService; 057 058/** 059 * Represents an OpenIG {@link Session} that will be stored as an encrypted JSON Web Token in a Cookie. 060 * The generated JWT is encrypted with the {@link JweAlgorithm#RSAES_PKCS1_V1_5} algorithm and {@link 061 * EncryptionMethod#A128CBC_HS256} method. 062 */ 063public class JwtCookieSession extends MapDecorator<String, Object> implements Session, DirtyListener { 064 065 /** 066 * Name of the cookie that will store the JWT session. 067 */ 068 public static final String OPENIG_JWT_SESSION = "openig-jwt-session"; 069 070 /** 071 * Based on the EXP claim concept from from rfc7519: The amount of time allowance between JWT expiring and current 072 * time when using EXP claim. Implementers MAY provide for some small leeway, usually no more than a few minutes, 073 * to account for clock skew. 074 */ 075 private static final long SKEW_ALLOWANCE = MILLISECONDS.convert(2L, MINUTES); 076 077 /** 078 * This key will hold the sessionTimeout value within the JWT session. 079 */ 080 private static final String IG_EXP_SESSION_KEY = "_ig_exp"; 081 082 /** 083 * Setting sessionTimeout to this date will effectively remove it from the user agent. 084 */ 085 private static final Date EPOCH = new Date(0L); 086 087 /** 088 * Know how to rebuild a JWT from a String. 089 */ 090 private final JwtReconstruction reader = new JwtReconstruction(); 091 092 /** 093 * Factory for JWT. 094 */ 095 private final JwtBuilderFactory factory = new JwtBuilderFactory(); 096 097 /** 098 * Marker used to detect if the session was used or not. 099 */ 100 private boolean dirty; 101 102 /** 103 * Name to be used for the JWT Cookie. 104 */ 105 private final String cookieName; 106 107 /** 108 * Logger used to output warnings about session's size. 109 */ 110 private final Logger logger; 111 112 /** 113 * Used for decryption/encryption of session's content. 114 */ 115 private final KeyPair pair; 116 117 /** 118 * The TimeService to use when setting the cookie session expiry time. 119 */ 120 private final TimeService timeService; 121 122 /** 123 * How long before the cookie session expires. 124 */ 125 private final Duration sessionTimeout; 126 127 /** 128 * Builds a new JwtCookieSession that will manage the given Request's session. 129 * 130 * @param request 131 * Request used to access {@literal Cookie} and {@literal Set-Cookie} headers. 132 * @param pair 133 * Secret key used to sign the JWT payload. 134 * @param cookieName 135 * Name to be used for the JWT Cookie. 136 * @param logger 137 * Logger 138 * @param timeService 139 * TimeService to use when dealing with cookie sessions 140 * @param sessionTimeout 141 * The duration of the cookie session 142 */ 143 public JwtCookieSession(final Request request, 144 final KeyPair pair, 145 final String cookieName, 146 final Logger logger, 147 final TimeService timeService, 148 final Duration sessionTimeout) { 149 super(new LinkedHashMap<String, Object>()); 150 this.pair = pair; 151 this.cookieName = cookieName; 152 this.logger = logger; 153 this.timeService = timeService; 154 155 // The MAX_SESSION_TIMEOUT is more than enough to mark a session to not expire 156 // so use this in place of larger values. 157 if (sessionTimeout.to(MILLISECONDS) > MAX_SESSION_TIMEOUT.to(MILLISECONDS)) { 158 this.sessionTimeout = MAX_SESSION_TIMEOUT; 159 } else { 160 this.sessionTimeout = sessionTimeout; 161 } 162 163 // TODO Make this lazy (intercept read methods) 164 loadJwtSession(request); 165 } 166 167 /** 168 * Load the session's content from the cookie (if any). 169 * 170 * @param request Request used to access {@literal Cookie} and {@literal Set-Cookie} headers. 171 */ 172 private void loadJwtSession(Request request) { 173 Cookie cookie = findJwtSessionCookie(request); 174 if (cookie != null) { 175 try { 176 EncryptedJwt jwt = reader.reconstructJwt(cookie.getValue(), EncryptedJwt.class); 177 jwt.decrypt(pair.getPrivate()); 178 JwtClaimsSet claimsSet = jwt.getClaimsSet(); 179 for (String key : claimsSet.keys()) { 180 // directly use super to avoid session be marked as dirty 181 super.put(key, claimsSet.getClaim(key)); 182 } 183 Number expiryTime = (Number) get(IG_EXP_SESSION_KEY); 184 if (expiryTime != null) { 185 if (isExpired(expiryTime)) { 186 logger.debug("The JWT Session Cookie has expired"); 187 clear(); 188 } 189 } else { 190 // No expiry time in the JWT: must be an old session from OpenIG 3.x 191 // Force a new entry, this will mark the session as dirty 192 // but will keep the session's content with an expiration date 193 put(IG_EXP_SESSION_KEY, getNewExpiryTime()); 194 } 195 } catch (JweDecryptionException e) { 196 dirty = true; // Force cookie expiration / overwrite. 197 logger.warning(format("The JWT Session Cookie '%s' could not be decrypted. This " 198 + "may be because temporary encryption keys have been used or if the " 199 + "configured encryption keys have changed since the JWT Session Cookie " 200 + "was created", cookieName)); 201 logger.debug(e); 202 } catch (Exception e) { 203 dirty = true; // Force cookie expiration / overwrite. 204 logger.warning(format("Cannot rebuild JWT Session from Cookie '%s'", cookieName)); 205 logger.debug(e); 206 } 207 } 208 } 209 210 @Override 211 public void onElementsRemoved() { 212 dirty = true; 213 } 214 215 @Override 216 public Object put(final String key, final Object value) { 217 // Put null into a key, results in the complete entry removal 218 if (value == null) { 219 return remove(key); 220 } 221 // Verify that the given value is JSON compatible 222 // This will throw an Exception if not 223 checkJsonCompatibility(key, value); 224 225 // Mark the session as dirty 226 dirty = true; 227 228 // Store the value 229 return super.put(key, value); 230 } 231 232 @Override 233 public void putAll(final Map<? extends String, ?> m) { 234 for (Entry<? extends String, ?> entry : m.entrySet()) { 235 put(entry.getKey(), entry.getValue()); 236 } 237 } 238 239 @Override 240 public Object remove(final Object key) { 241 dirty = true; 242 return super.remove(key); 243 } 244 245 @Override 246 public void clear() { 247 dirty = true; 248 super.clear(); 249 } 250 251 @Override 252 public Set<String> keySet() { 253 return new DirtySet<>(super.keySet(), this); 254 } 255 256 @Override 257 public Collection<Object> values() { 258 return new DirtyCollection<>(super.values(), this); 259 } 260 261 @Override 262 public Set<Entry<String, Object>> entrySet() { 263 return new DirtySet<>(super.entrySet(), this); 264 } 265 266 @Override 267 public void save(Response response) throws IOException { 268 // Only build the JWT session if the session is dirty 269 if (dirty) { 270 Reject.ifNull(response, "Cannot save session state on a null response"); 271 // Update the Set-Cookie header 272 final Cookie jwtCookie; 273 if (isEmpty()) { 274 jwtCookie = buildExpiredJwtCookie(); 275 } else { 276 jwtCookie = buildJwtCookie(); 277 String value = jwtCookie.getValue(); 278 if (value.length() > 4096) { 279 throw new IOException( 280 format("JWT session is too large (%d chars), failing the request because " 281 + "session does not support serialized content that is larger than 4KB " 282 + "(Http Cookie limitation)", value.length())); 283 } 284 if (value.length() > 3072) { 285 logger.warning(format( 286 "Current JWT session's size (%d chars) is quite close to the 4KB limit. Maybe " 287 + "consider using the traditional Http-based session (the default), or place" 288 + "less objects in the session", value.length())); 289 } 290 } 291 response.getHeaders().add(new SetCookieHeader(singletonList(jwtCookie))); 292 } 293 294 } 295 296 @Override 297 public boolean isEmpty() { 298 299 // If the only item is the IG_EXP_SESSION_KEY then it should be considered empty 300 if (!super.isEmpty()) { 301 return super.size() == 1 && super.containsKey(IG_EXP_SESSION_KEY); 302 } else { 303 return true; 304 } 305 } 306 307 private Cookie buildExpiredJwtCookie() { 308 return new Cookie().setPath("/").setName(cookieName).setExpires(EPOCH); 309 } 310 311 private Cookie buildJwtCookie() { 312 // Reuse existing expiryTime if it exists. 313 // If the value fits within a Integer, then an Integer rather than a Long is returned. 314 Number expiryTime = (Number) get(IG_EXP_SESSION_KEY); 315 if (expiryTime == null) { 316 expiryTime = getNewExpiryTime(); 317 super.put(IG_EXP_SESSION_KEY, expiryTime.longValue()); 318 } 319 return new Cookie() 320 .setPath("/") 321 .setName(cookieName) 322 .setValue(buildJwtSession()) 323 .setExpires(new Date(expiryTime.longValue())); 324 } 325 326 /** 327 * Builds a JWT from the session's content. 328 */ 329 private String buildJwtSession() { 330 EncryptedJwtBuilder jwtBuilder = factory.jwe(pair.getPublic()); 331 JwtClaimsSetBuilder claimsBuilder = factory.claims(); 332 claimsBuilder.claims(this); 333 jwtBuilder.claims(claimsBuilder.build()); 334 jwtBuilder.headers() 335 .alg(JweAlgorithm.RSAES_PKCS1_V1_5) 336 .enc(EncryptionMethod.A128CBC_HS256); 337 return jwtBuilder.build(); 338 } 339 340 /** 341 * Find if there is an existing cookie storing a JWT session. 342 * 343 * @param request Request used to access {@literal Cookie} and {@literal Set-Cookie} headers. 344 * @return a {@link Cookie} if found, {@literal null} otherwise. 345 */ 346 private Cookie findJwtSessionCookie(Request request) { 347 List<Cookie> cookies = request.getCookies().get(cookieName); 348 if (cookies != null) { 349 return cookies.get(0); 350 } 351 return null; 352 } 353 354 private Long getNewExpiryTime() { 355 return timeService.now() + sessionTimeout.to(MILLISECONDS); 356 } 357 358 private boolean isExpired(Number expiryTime) { 359 return expiryTime.longValue() <= (timeService.now() - SKEW_ALLOWANCE); 360 } 361}