1 package edu.internet2.middleware.shibboleth.wayf.plugins.provider;
2
3 import java.io.UnsupportedEncodingException;
4 import java.net.URLDecoder;
5 import java.net.URLEncoder;
6 import java.util.ArrayList;
7 import java.util.Collection;
8 import java.util.Iterator;
9 import java.util.List;
10 import java.util.Map;
11
12 import javax.servlet.http.Cookie;
13 import javax.servlet.http.HttpServletRequest;
14 import javax.servlet.http.HttpServletResponse;
15
16 import org.apache.log4j.Logger;
17 import org.opensaml.saml2.metadata.provider.MetadataProvider;
18 import org.opensaml.xml.util.Base64;
19 import org.w3c.dom.Element;
20
21 import edu.internet2.middleware.shibboleth.wayf.DiscoveryServiceHandler;
22 import edu.internet2.middleware.shibboleth.wayf.IdPSite;
23 import edu.internet2.middleware.shibboleth.wayf.WayfException;
24 import edu.internet2.middleware.shibboleth.wayf.plugins.Plugin;
25 import edu.internet2.middleware.shibboleth.wayf.plugins.PluginContext;
26 import edu.internet2.middleware.shibboleth.wayf.plugins.PluginMetadataParameter;
27 import edu.internet2.middleware.shibboleth.wayf.plugins.WayfRequestHandled;
28
29 /**
30 * This is a test implementation of the saml cookie lookup stuff to
31 * see whether it fits the plugin architecture.
32 *
33 * @author Rod Widdowson
34 *
35 */
36 public class SamlCookiePlugin implements Plugin {
37
38 /**
39 * The parameter which controls the cache.
40 */
41 private static final String PARAMETER_NAME = "cache";
42
43 /**
44 * Parameter to say make it last a long time.
45 */
46 private static final String PARAMETER_PERM = "perm";
47
48 /**
49 * Parameter to say just keep this as long as the brower is open.
50 */
51 private static final String PARAMETER_SESSION = "session";
52
53 /**
54 * Handle for logging.
55 */
56 private static Logger log = Logger.getLogger(SamlCookiePlugin.class.getName());
57
58 /**
59 * As specified in the SAML2 profiles specification.
60 */
61 private static final String COOKIE_NAME = "_saml_idp";
62
63 /**
64 * By default we keep the cookie around for a week.
65 */
66 private static final int DEFAULT_CACHE_EXPIRATION = 6048000;
67
68 /**
69 * Do we always go where the cookie tells us, or do we just provide the cookie as a hint.
70 */
71 private boolean alwaysFollow;
72
73 /**
74 * Is our job to clean up the cookie.
75 */
76 private boolean deleteCookie;
77
78 /**
79 * Lipservice towards having a common domain cookie.
80 */
81 private String cacheDomain;
82
83 /**
84 * How long the cookie our will be active?
85 */
86 private int cacheExpiration;
87
88 /**
89 * This constructor is called during wayf initialization with it's
90 * own little bit of XML config.
91 *
92 * @param element - further information to be gleaned from the DOM.
93 */
94 public SamlCookiePlugin(Element element) {
95 /*
96 * <Plugin idenfifier="WayfCookiePlugin"
97 * type="edu.internet2.middleware.shibboleth.wayf.plugins.provider.SamlCookiePlugin"
98 * alwaysFollow = "FALSE"
99 * deleteCookie = "FALSE"
100 * cacheExpiration = "number"
101 * cacheDomain = "string"/>
102 */
103 log.info("New plugin");
104 String s;
105
106 s = element.getAttribute("alwaysFollow");
107 if (s != null && !s.equals("") ) {
108 alwaysFollow = Boolean.valueOf(s).booleanValue();
109 } else {
110 alwaysFollow = true;
111 }
112
113 s = element.getAttribute("deleteCookie");
114 if (s != null && !s.equals("")) {
115 deleteCookie = Boolean.valueOf(s).booleanValue();
116 } else {
117 deleteCookie = false;
118 }
119
120 s = element.getAttribute("cacheDomain");
121 if ((s != null) && !s.equals("")) {
122 cacheDomain = s;
123 } else {
124 cacheDomain = "";
125 }
126
127 s = element.getAttribute("cacheExpiration");
128 if ((s != null) && !s.equals("")) {
129
130 try {
131
132 cacheExpiration = Integer.parseInt(s);
133 } catch (NumberFormatException ex) {
134
135 log.error("Invalid CacheExpiration value - " + s);
136 cacheExpiration = DEFAULT_CACHE_EXPIRATION;
137 }
138 } else {
139 cacheExpiration = DEFAULT_CACHE_EXPIRATION;
140 }
141 }
142
143 /**
144 * Create a plugin with the hard-wired default settings.
145 */
146 private SamlCookiePlugin() {
147 alwaysFollow = false;
148 deleteCookie = false;
149 cacheExpiration = DEFAULT_CACHE_EXPIRATION;
150 }
151
152 /**
153 * This is the 'hook' in the lookup part of Discovery Service processing.
154 *
155 * @param req - Describes the current request. Used to find any appropriate cookies
156 * @param res - Describes the current response. Used to redirect the request.
157 * @param parameter - Describes the metadata.
158 * @param context - Any processing context returned from a previous call. We set this on first call and
159 * use non null to indicate that we don't go there again.
160 * @param validIdps The list of IdPs which is currently views as possibly matches for the pattern.
161 * The Key is the EntityId for the IdP and the value the object which describes
162 * the Idp
163 * @param idpList The set of Idps which are currently considered as potential hints.
164 * @return a context to hand to subsequent calls
165 * @throws WayfRequestHandled if the plugin has handled the request.
166 * issues a redirect)
167 *
168 * @see edu.internet2.middleware.shibboleth.wayf.plugins.Plugin#lookup
169 */
170 public PluginContext lookup(HttpServletRequest req,
171 HttpServletResponse res,
172 PluginMetadataParameter parameter,
173 Map<String, IdPSite> validIdps,
174 PluginContext context,
175 List <IdPSite> idpList) throws WayfRequestHandled {
176
177 if (context != null) {
178 //
179 // We only need to be called once
180 //
181 return context;
182 }
183
184 if (deleteCookie) {
185 deleteCookie(req, res);
186 //
187 // Only need to be called once - so set up a parameter
188 //
189 return new Context() ;
190 }
191 List <String> idps = getIdPCookie(req, res, cacheDomain).getIdPList();
192
193 for (String idpName : idps) {
194 IdPSite idp = validIdps.get(idpName);
195 if (idp != null) {
196 if (alwaysFollow) {
197 try {
198 DiscoveryServiceHandler.forwardRequest(req, res, idp);
199 } catch (WayfException e) {
200 // Do nothing we are going to throw anyway
201 ;
202 }
203 throw new WayfRequestHandled();
204 }
205 //
206 // This IDP is ok
207 //
208 idpList.add(idp);
209 }
210 }
211
212 return null;
213 }
214
215 /**
216 * Plugin point which is called when the data is refreshed.
217 * @param metadata - where to get the data from.
218 * @return the value which will be provided as input to subsequent calls
219 * @see edu.internet2.middleware.shibboleth.wayf.plugins.Plugin#refreshMetadata
220 */
221 public PluginMetadataParameter refreshMetadata(MetadataProvider metadata) {
222 //
223 // We don't care about metadata - we are given all that we need
224 //
225 return null;
226 }
227
228 /**
229 * Plgin point for searching.
230 *
231 * @throws WayfRequestHandled
232 * @param req Describes the current request.
233 * @param res Describes the current response.
234 * @param parameter Describes the metadata.
235 * @param pattern What we are searchign for.
236 * @param validIdps The list of IdPs which is currently views as possibly matches for the pattern.
237 * The Key is the EntityId for the IdP and the value the object which describes
238 * the Idp
239 * @param context Any processing context returned from a previous call. We set this on first call and
240 * use non null to indicate that we don't go there again.
241 * @param searchResult What the search yielded.
242 * @param idpList The set of Idps which are currently considered as potential hints.
243 * @return a context to hand to subsequent calls.
244 * @see edu.internet2.middleware.shibboleth.wayf.plugins.Plugin#search
245 * @throws WayfRequestHandled if the plugin has handled the request.
246 *
247 */
248 public PluginContext search(HttpServletRequest req,
249 HttpServletResponse res,
250 PluginMetadataParameter parameter,
251 String pattern,
252 Map<String, IdPSite> validIdps,
253 PluginContext context,
254 Collection<IdPSite> searchResult,
255 List<IdPSite> idpList) throws WayfRequestHandled {
256 //
257 // Don't distinguish between lookup and search
258 //
259 return lookup(req, res, parameter, validIdps, context, idpList);
260 }
261
262 /**
263 * Plugin point for selection.
264 *
265 * @see edu.internet2.middleware.shibboleth.wayf.plugins.Plugin#selected(javax.servlet.http.HttpServletRequest.
266 * javax.servlet.http.HttpServletResponse,
267 * edu.internet2.middleware.shibboleth.wayf.plugins.PluginMetadataParameter,
268 * java.lang.String)
269 * @param req Describes the current request.
270 * @param res Describes the current response.
271 * @param parameter Describes the metadata.
272 * @param idP Describes the idp.
273 *
274 */
275 public void selected(HttpServletRequest req, HttpServletResponse res,
276 PluginMetadataParameter parameter, String idP) {
277
278 SamlIdPCookie cookie = getIdPCookie(req, res, cacheDomain);
279 String param = req.getParameter(PARAMETER_NAME);
280
281 if (null == param || param.equals("")) {
282 return;
283 } else if (param.equalsIgnoreCase(PARAMETER_SESSION)) {
284 cookie.addIdPName(idP, -1);
285 } else if (param.equalsIgnoreCase(PARAMETER_PERM)) {
286 cookie.addIdPName(idP, cacheExpiration);
287 }
288 }
289
290 //
291 // Private classes for internal use
292 //
293
294 /**
295 * This is just a marker tag.
296 */
297 private static class Context implements PluginContext {}
298
299 /**
300 * Class to abstract away the saml cookie for us.
301 */
302 public final class SamlIdPCookie {
303
304
305 /**
306 * The associated request.
307 */
308 private final HttpServletRequest req;
309 /**
310 * The associated response.
311 */
312 private final HttpServletResponse res;
313 /**
314 * The associated domain.
315 */
316 private final String domain;
317 /**
318 * The IdPs.
319 */
320 private final List <String> idPList = new ArrayList<String>();
321
322 /**
323 * Constructs a <code>SamlIdPCookie</code> from the provided string (which is the raw data.
324 *
325 * @param codedData
326 * the information read from the cookie
327 * @param request Describes the current request.
328 * @param response Describes the current response.
329 * @param domainName - if non null the domain for any *created* cookie.
330 */
331 private SamlIdPCookie(String codedData,
332 HttpServletRequest request,
333 HttpServletResponse response,
334 String domainName) {
335
336 this.req = request;
337 this.res = response;
338 this.domain = domainName;
339
340 int start;
341 int end;
342
343 if (codedData == null || codedData.equals("")) {
344 log.info("Empty cookie");
345 return;
346 }
347 //
348 // An earlier version saved the cookie without URL encoding it, hence there may be
349 // spaces which in turn means we may be quoted. Strip any quotes.
350 //
351 if (codedData.charAt(0) == '"' && codedData.charAt(codedData.length()-1) == '"') {
352 codedData = codedData.substring(1,codedData.length()-1);
353 }
354
355 try {
356 codedData = URLDecoder.decode(codedData, "UTF-8");
357 } catch (UnsupportedEncodingException e) {
358 log.error("could not decode cookie");
359 return;
360 }
361
362 start = 0;
363 end = codedData.indexOf(' ', start);
364 while (end > 0) {
365 String value = codedData.substring(start, end);
366 start = end + 1;
367 end = codedData.indexOf(' ', start);
368 if (!value.equals("")) {
369 idPList.add(new String(Base64.decode(value)));
370 }
371 }
372 if (start < codedData.length()) {
373 String value = codedData.substring(start);
374 if (!value.equals("")) {
375 idPList.add(new String(Base64.decode(value)));
376 }
377 }
378 }
379 /**
380 * Create a SamlCookie with no data inside.
381 * @param domainName - if non null, the domain of the new cookie
382 * @param request Describes the current request.
383 * @param response Describes the current response.
384 *
385 */
386 private SamlIdPCookie(HttpServletRequest request, HttpServletResponse response, String domainName) {
387 this.req = request;
388 this.res = response;
389 this.domain = domainName;
390 }
391
392 /**
393 * Add the specified Shibboleth IdP Name to the cookie list or move to
394 * the front and then write it back.
395 *
396 * We always add to the front (and remove from wherever it was)
397 *
398 * @param idPName - The name to be added
399 * @param expiration - The expiration of the cookie or zero if it is to be unchanged
400 */
401 private void addIdPName(String idPName, int expiration) {
402
403 idPList.remove(idPName);
404 idPList.add(0, idPName);
405
406 writeCookie(expiration);
407 }
408
409 /**
410 * Delete the <b>entire<\b> cookie contents
411 */
412
413
414 /**
415 * Remove origin from the cachedata and write it back.
416 *
417 * @param origin what to remove.
418 * @param expiration How long it will live.
419 */
420
421 public void deleteIdPName(String origin, int expiration) {
422 idPList.remove(origin);
423 writeCookie(expiration);
424 }
425
426 /**
427 * Write back the cookie.
428 *
429 * @param expiration How long it will live
430 */
431 private void writeCookie(int expiration) {
432 Cookie cookie = getCookie(req);
433
434 if (idPList.size() == 0) {
435 //
436 // Nothing to write, so delete the cookie
437 //
438 cookie.setPath("/");
439 cookie.setMaxAge(0);
440 res.addCookie(cookie);
441 return;
442 }
443
444 //
445 // Otherwise encode up the cookie
446 //
447 StringBuffer buffer = new StringBuffer();
448 Iterator <String> it = idPList.iterator();
449
450 while (it.hasNext()) {
451 String next = it.next();
452 String what = new String(Base64.encodeBytes(next.getBytes()));
453 buffer.append(what).append(' ');
454 }
455
456 String value;
457 try {
458 value = URLEncoder.encode(buffer.toString(), "UTF-8");
459 } catch (UnsupportedEncodingException e) {
460 log.error("Could not encode cookie");
461 return;
462 }
463
464 if (cookie == null) {
465 cookie = new Cookie(COOKIE_NAME, value);
466 } else {
467 cookie.setValue(value);
468 }
469 cookie.setComment("Used to cache selection of a user's Shibboleth IdP");
470 cookie.setPath("/");
471
472
473 cookie.setMaxAge(expiration);
474
475 if (domain != null && domain != "") {
476 cookie.setDomain(domain);
477 }
478 res.addCookie(cookie);
479
480 }
481
482 /**
483 * Return the list of Idps for this cookie.
484 * @return The list.
485 */
486 public List <String> getIdPList() {
487 return idPList;
488 }
489 }
490
491 /**
492 * Extract the cookie from a request.
493 * @param req the request.
494 * @return the cookie.
495 */
496 private static Cookie getCookie(HttpServletRequest req) {
497
498 Cookie[] cookies = req.getCookies();
499 if (cookies != null) {
500 for (int i = 0; i < cookies.length; i++) {
501 if (cookies[i].getName().equals(COOKIE_NAME)) {
502 return cookies[i];
503 }
504 }
505 }
506 return null;
507 }
508
509 /**
510 * Delete the cookie from the response.
511 * @param req The request.
512 * @param res The response.
513 */
514 private static void deleteCookie(HttpServletRequest req, HttpServletResponse res) {
515 Cookie cookie = getCookie(req);
516
517 if (cookie == null) {
518 return;
519 }
520
521 cookie.setPath("/");
522 cookie.setMaxAge(0);
523 res.addCookie(cookie);
524 }
525 /**
526 * Load up the cookie and convert it into a SamlIdPCookie. If there is no
527 * underlying cookie return a null one.
528 * @param req The request.
529 * @param res The response.
530 * @param domain - if this is set then any <b>created</b> cookies are set to this domain
531 * @return the new object.
532 */
533
534 private SamlIdPCookie getIdPCookie(HttpServletRequest req, HttpServletResponse res, String domain) {
535 Cookie cookie = getCookie(req);
536
537 if (cookie == null) {
538 return new SamlIdPCookie(req, res, domain);
539 } else {
540 return new SamlIdPCookie(cookie.getValue(), req, res, domain);
541 }
542 }
543 }
544