d43fb849553a9508c0c63d3bc9d64b90a6c36a1e
[java-idp.git] / src / edu / internet2 / middleware / shibboleth / common / provider / CookieCache.java
1 /*
2  * Copyright [2005] [University Corporation for Advanced Internet Development, Inc.]
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  * http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16
17 package edu.internet2.middleware.shibboleth.common.provider;
18
19 import java.io.ByteArrayInputStream;
20 import java.io.ByteArrayOutputStream;
21 import java.io.ObjectInputStream;
22 import java.io.ObjectOutputStream;
23 import java.io.StringReader;
24 import java.io.StringWriter;
25 import java.security.GeneralSecurityException;
26 import java.security.SecureRandom;
27 import java.util.ArrayList;
28 import java.util.Arrays;
29 import java.util.Collection;
30 import java.util.Date;
31 import java.util.HashMap;
32 import java.util.List;
33 import java.util.Map;
34 import java.util.Map.Entry;
35 import java.util.zip.GZIPInputStream;
36 import java.util.zip.GZIPOutputStream;
37
38 import javax.crypto.Cipher;
39 import javax.crypto.Mac;
40 import javax.crypto.SecretKey;
41 import javax.crypto.spec.IvParameterSpec;
42 import javax.servlet.http.Cookie;
43 import javax.servlet.http.HttpServletRequest;
44 import javax.servlet.http.HttpServletResponse;
45 import javax.xml.parsers.DocumentBuilderFactory;
46 import javax.xml.transform.OutputKeys;
47 import javax.xml.transform.Transformer;
48 import javax.xml.transform.TransformerFactory;
49 import javax.xml.transform.dom.DOMSource;
50 import javax.xml.transform.stream.StreamResult;
51
52 import org.apache.log4j.Logger;
53 import org.w3c.dom.Document;
54 import org.w3c.dom.Element;
55 import org.w3c.dom.NodeList;
56 import org.xml.sax.InputSource;
57
58 import edu.internet2.middleware.shibboleth.common.Cache;
59 import edu.internet2.middleware.shibboleth.common.CacheException;
60 import edu.internet2.middleware.shibboleth.utils.Base32;
61
62 /**
63  * <code>Cache</code> implementation that uses browser cookies to store data. Symmetric and HMAC algorithms are used
64  * to encrypt and verify the data. Due to the size limitations of cookie storage, data may interleaved among multiple
65  * cookies. NOTE: Using this cache implementation in a standalon tomcat configuration will usually require that the
66  * "maxHttpHeaderSize" parameter be greatly increased.
67  * 
68  * @author Walter Hoehn
69  */
70 public class CookieCache extends BaseCache implements Cache {
71
72         private static Logger log = Logger.getLogger(CookieCache.class.getName());
73         private HttpServletResponse response;
74         private Collection<Cookie> myCurrentCookies = new ArrayList<Cookie>();
75         private Map<String, CacheEntry> dataCache = new HashMap<String, CacheEntry>();
76         private static final int CHUNK_SIZE = 4 * 1024; // minimal browser requirement
77         private static final int COOKIE_LIMIT = 20; // minimal browser requirement
78         private static final String NAME_PREFIX = "IDP_CACHE:";
79         private static int totalCookies = 0;
80         protected SecretKey secret;
81         private static SecureRandom random = new SecureRandom();
82         private String cipherAlgorithm;
83         private String macAlgorithm;
84
85         public CookieCache(String name, SecretKey key, String cipherAlgorithm, String macAlgorithm,
86                         HttpServletRequest request, HttpServletResponse response) throws CacheException {
87
88                 super(name, Cache.CacheType.CLIENT_SIDE);
89                 this.secret = key;
90                 this.cipherAlgorithm = cipherAlgorithm;
91                 this.macAlgorithm = macAlgorithm;
92                 this.response = response;
93                 Cookie[] requestCookies = request.getCookies();
94                 if (requestCookies != null) {
95                         for (int i = 0; i < requestCookies.length; i++) {
96                                 if (requestCookies[i].getName().startsWith(NAME_PREFIX + getName())
97                                                 && requestCookies[i].getValue() != null) {
98                                         myCurrentCookies.add(requestCookies[i]);
99                                 }
100                         }
101                 }
102
103                 if (usingDefaultSecret()) {
104                         log.warn("You are running the Cookie Cache with the "
105                                         + "default secret key.  This is UNSAFE!  Please change "
106                                         + "this configuration and restart the IdP.");
107                 }
108
109                 initFromCookies();
110         }
111
112         public void postProcessing() throws CacheException {
113
114                 if (totalCookies > (COOKIE_LIMIT - 1)) {
115                         log.warn("The Cookie Cache mechanism is about to write a large amount of data to the "
116                                         + "client.  This may not work with some browser software, so it is recommended"
117                                         + " that you investigate other caching mechanisms.");
118                 }
119
120                 flushCache();
121         }
122
123         public boolean contains(String key) throws CacheException {
124
125                 CacheEntry entry = dataCache.get(key);
126
127                 if (entry == null) { return false; }
128
129                 // Clean cache if it is expired
130                 if ((((CacheEntry) entry).isExpired())) {
131                         log.debug("Found expired object.  Deleting...");
132                         totalCookies--;
133                         dataCache.remove(key);
134                         return false;
135                 }
136
137                 // OK, we have it
138                 return true;
139         }
140
141         public String retrieve(String key) throws CacheException {
142
143                 CacheEntry entry = dataCache.get(key);
144
145                 if (entry == null) { return null; }
146
147                 // Clean cache if it is expired
148                 if ((((CacheEntry) entry).isExpired())) {
149                         log.debug("Found expired object.  Deleting...");
150                         totalCookies--;
151                         dataCache.remove(key);
152                         return null;
153                 }
154
155                 // OK, we have it
156                 return entry.value;
157         }
158
159         public void remove(String key) throws CacheException {
160
161                 dataCache.remove(key);
162                 totalCookies--;
163         }
164
165         public void store(String key, String value, long duration) throws CacheException {
166
167                 dataCache.put(key, new CacheEntry(value, duration));
168                 totalCookies++;
169         }
170
171         private void initFromCookies() throws CacheException {
172
173                 log.debug("Attempting to initialize cache from client-supplied cookies.");
174                 // Pull data from cookies
175                 List<Cookie> relevantCookies = new ArrayList<Cookie>();
176                 for (Cookie cookie : myCurrentCookies) {
177                         if (cookie.getName().startsWith(NAME_PREFIX + getName())) {
178                                 relevantCookies.add(cookie);
179                         }
180                 }
181                 if (relevantCookies.isEmpty()) {
182                         log.debug("No applicable cookies found.  Cache is empty.");
183                         return;
184                 }
185
186                 // Sort
187                 String[] sortedCookieValues = new String[relevantCookies.size()];
188                 for (Cookie cookie : relevantCookies) {
189                         String[] tokenizedName = cookie.getName().split(":");
190                         sortedCookieValues[Integer.parseInt(tokenizedName[tokenizedName.length - 1]) - 1] = cookie.getValue();
191                 }
192                 // Concatenate
193                 StringBuffer concat = new StringBuffer();
194                 for (String cookieValue : sortedCookieValues) {
195                         concat.append(cookieValue);
196                 }
197                 log.debug("Dumping Encrypted/Encoded Input Cache: " + concat);
198
199                 try {
200                         // Decode Base32
201                         byte[] in = Base32.decode(concat.toString());
202
203                         // Decrypt
204                         Cipher cipher = Cipher.getInstance(cipherAlgorithm);
205                         int ivSize = cipher.getBlockSize();
206                         byte[] iv = new byte[ivSize];
207                         Mac mac = Mac.getInstance(macAlgorithm);
208                         mac.init(secret);
209                         int macSize = mac.getMacLength();
210                         if (in.length < ivSize) {
211                                 log.error("Cache is malformed (not enough bytes).");
212                                 throw new CacheException("Cache is malformed (not enough bytes).");
213                         }
214
215                         // extract the IV, setup the cipher and extract the encrypted data
216                         System.arraycopy(in, 0, iv, 0, ivSize);
217                         IvParameterSpec ivSpec = new IvParameterSpec(iv);
218                         cipher.init(Cipher.DECRYPT_MODE, secret, ivSpec);
219                         byte[] encryptedData = new byte[in.length - iv.length];
220                         System.arraycopy(in, ivSize, encryptedData, 0, in.length - iv.length);
221
222                         // decrypt the rest of the data andsetup the streams
223                         byte[] decryptedBytes = cipher.doFinal(encryptedData);
224                         ByteArrayInputStream byteStream = new ByteArrayInputStream(decryptedBytes);
225                         GZIPInputStream compressedData = new GZIPInputStream(byteStream);
226                         ObjectInputStream dataStream = new ObjectInputStream(compressedData);
227
228                         // extract the components
229                         byte[] decodedMac = new byte[macSize];
230
231                         int bytesRead = dataStream.read(decodedMac);
232                         if (bytesRead != macSize) {
233                                 log.error("Error parsing cache: Unable to extract HMAC.");
234                                 throw new CacheException("Error parsing cache: Unable to extract HMAC.");
235                         }
236
237                         String decodedData = (String) dataStream.readObject();
238                         log.debug("Dumping Raw Input Cache: " + decodedData);
239
240                         // Verify HMAC
241                         byte[] generatedMac = mac.doFinal(decodedData.getBytes());
242                         if (!Arrays.equals(decodedMac, generatedMac)) {
243                                 log.error("Cookie cache data failed integrity  check.");
244                                 throw new GeneralSecurityException("Cookie cache data failed integrity check.");
245                         }
246
247                         // Parse XML
248                         DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
249                         factory.setValidating(false);
250                         factory.setNamespaceAware(false);
251                         Element cacheElement = factory.newDocumentBuilder().parse(new InputSource(new StringReader(decodedData)))
252                                         .getDocumentElement();
253                         NodeList items = cacheElement.getElementsByTagName("Item");
254                         for (int i = 0; i < items.getLength(); i++) {
255                                 Element item = (Element) items.item(i);
256                                 totalCookies++;
257                                 dataCache.put(item.getAttribute("key"), new CacheEntry(item.getAttribute("value"), new Date(new Long(
258                                                 item.getAttribute("expire")))));
259                         }
260
261                 } catch (Exception e) {
262                         log.error("Error decrypting cache data: " + e);
263                         throw new CacheException("Unable to read cached data.");
264                 }
265         }
266
267         private boolean usingDefaultSecret() {
268
269                 byte[] defaultKey = new byte[]{(byte) 0xC7, (byte) 0x49, (byte) 0x80, (byte) 0xD3, (byte) 0x02, (byte) 0x4A,
270                                 (byte) 0x61, (byte) 0xEF, (byte) 0x25, (byte) 0x5D, (byte) 0xE3, (byte) 0x2F, (byte) 0x57, (byte) 0x51,
271                                 (byte) 0x20, (byte) 0x15, (byte) 0xC7, (byte) 0x49, (byte) 0x80, (byte) 0xD3, (byte) 0x02, (byte) 0x4A,
272                                 (byte) 0x61, (byte) 0xEF};
273                 byte[] encodedKey = secret.getEncoded();
274                 return Arrays.equals(defaultKey, encodedKey);
275         }
276
277         /**
278          * Secures, encodes, and writes out (to cookies) cached data.
279          */
280         private void flushCache() throws CacheException {
281
282                 log.debug("Flushing cache.");
283                 log.debug("Encrypting cache data.");
284
285                 // Create XML/String representation of all cache data
286                 String stringData = null;
287
288                 try {
289
290                         DocumentBuilderFactory docFactory = DocumentBuilderFactory.newInstance();
291                         docFactory.setNamespaceAware(false);
292                         Document placeHolder = docFactory.newDocumentBuilder().newDocument();
293
294                         Element cacheNode = placeHolder.createElement("Cache");
295                         for (Entry<String, CacheEntry> entry : dataCache.entrySet()) {
296                                 Element itemNode = placeHolder.createElement("Item");
297                                 itemNode.setAttribute("key", entry.getKey());
298                                 itemNode.setAttribute("value", entry.getValue().value);
299                                 itemNode.setAttribute("expire", new Long(entry.getValue().expiration.getTime()).toString());
300                                 cacheNode.appendChild(itemNode);
301                         }
302
303                         TransformerFactory factory = TransformerFactory.newInstance();
304                         DOMSource source = new DOMSource(cacheNode);
305                         Transformer transformer = factory.newTransformer();
306                         transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes");
307                         StringWriter stringWriter = new StringWriter();
308                         StreamResult result = new StreamResult(stringWriter);
309                         transformer.transform(source, result);
310                         stringData = stringWriter.toString().replaceAll(">\\s<", "><");
311                         log.debug("Dumping Raw Cache: " + stringData);
312
313                 } catch (Exception e) {
314                         log.error("Error encoding cache data: " + e);
315                         throw new CacheException("Unable to cache data.");
316                 }
317
318                 try {
319
320                         // Setup a gzipped data stream
321                         ByteArrayOutputStream byteStream = new ByteArrayOutputStream();
322                         GZIPOutputStream compressedStream = new GZIPOutputStream(byteStream);
323                         ObjectOutputStream dataStream = new ObjectOutputStream(compressedStream);
324
325                         // Write data and HMAC to stream
326                         Mac mac = Mac.getInstance(macAlgorithm);
327                         mac.init(secret);
328                         dataStream.write(mac.doFinal(stringData.getBytes()));
329                         dataStream.writeObject(stringData);
330
331                         // Flush
332                         // dataStream.flush();
333                         compressedStream.flush();
334                         compressedStream.finish();
335                         byteStream.flush();
336
337                         // Setup encryption lib
338                         Cipher cipher = Cipher.getInstance(cipherAlgorithm);
339                         byte[] iv = new byte[cipher.getBlockSize()];
340                         random.nextBytes(iv);
341                         IvParameterSpec ivSpec = new IvParameterSpec(iv);
342                         cipher.init(Cipher.ENCRYPT_MODE, secret, ivSpec);
343
344                         // Creat byte array of IV and encrypted cache
345                         byte[] encryptedData = cipher.doFinal(byteStream.toByteArray());
346                         byte[] cacheBytes = new byte[iv.length + encryptedData.length];
347                         // Write IV
348                         System.arraycopy(iv, 0, cacheBytes, 0, iv.length);
349                         // Write encrypted cache
350                         System.arraycopy(encryptedData, 0, cacheBytes, iv.length, encryptedData.length);
351
352                         // Base32 encode
353                         String encodedData = Base32.encode(cacheBytes);
354                         log.debug("Dumping Encrypted/Encoded Cache: " + encodedData);
355
356                         // Put into cookies
357                         interleaveInCookies(encodedData);
358
359                 } catch (Exception e) {
360                         log.error("Error encrypting cache data: " + e);
361                         throw new CacheException("Unable to cache data.");
362                 }
363         }
364
365         /**
366          * Writes encoded data across multiple cookies
367          */
368         private void interleaveInCookies(String data) {
369
370                 log.debug("Writing cache to cookies.");
371
372                 // Convert the String data to a list of cookies
373                 Map<String, Cookie> cookiesToResponse = new HashMap<String, Cookie>();
374                 StringBuffer bufferredData = new StringBuffer(data);
375                 int i = 1;
376                 while (bufferredData != null && bufferredData.length() > 0) {
377                         Cookie cookie = null;
378                         String name = NAME_PREFIX + getName() + ":" + i++;
379                         if (bufferredData.length() <= getCookieSpace(name)) {
380                                 cookie = new Cookie(name, bufferredData.toString());
381                                 bufferredData = null;
382                         } else {
383                                 cookie = new Cookie(name, bufferredData.substring(0, getCookieSpace(name) - 1));
384                                 bufferredData.delete(0, getCookieSpace(name) - 1);
385                         }
386                         cookiesToResponse.put(cookie.getName(), cookie);
387                 }
388
389                 // Expire cookies that we used previously but no longer need
390                 for (Cookie currCookie : myCurrentCookies) {
391                         if (!cookiesToResponse.containsKey(currCookie.getName())) {
392                                 currCookie.setMaxAge(0);
393                                 currCookie.setValue(null);
394                                 cookiesToResponse.put(currCookie.getName(), currCookie);
395                         }
396                 }
397
398                 // Write our cookies to the response object
399                 for (Cookie cookie : cookiesToResponse.values()) {
400                         response.addCookie(cookie);
401                 }
402
403                 // Update our cached copy of the cookies
404                 myCurrentCookies = cookiesToResponse.values();
405         }
406
407         /**
408          * Returns the amount of value space available in cookies we create
409          */
410         private int getCookieSpace(String cookieName) {
411
412                 // If we add other cookie variables, we would need to adjust this algorithm appropriately
413                 StringBuffer used = new StringBuffer();
414                 used.append("Set-Cookie: ");
415                 used.append(cookieName + "=" + " ");
416                 return CHUNK_SIZE - used.length() - 2;
417         }
418
419 }