Code cleanups in preparation for 2.0 work.
[java-idp.git] / src / edu / internet2 / middleware / shibboleth / idp / provider / ADFS_SSOHandler.java
1 /*
2  * Copyright [2005] [University Corporation for Advanced Internet Development, Inc.] Licensed under the Apache License,
3  * Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy
4  * of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in
5  * writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS
6  * OF ANY KIND, either express or implied. See the License for the specific language governing permissions and
7  * limitations under the License.
8  */
9
10 package edu.internet2.middleware.shibboleth.idp.provider;
11
12 import java.io.IOException;
13 import java.util.ArrayList;
14 import java.util.Arrays;
15 import java.util.Collection;
16 import java.util.Date;
17 import java.util.Iterator;
18 import java.util.Vector;
19
20 import javax.servlet.RequestDispatcher;
21 import javax.servlet.ServletException;
22 import javax.servlet.http.HttpServletRequest;
23 import javax.servlet.http.HttpServletResponse;
24
25 import org.apache.commons.codec.binary.Base64;
26 import org.apache.log4j.Logger;
27 import org.apache.xml.security.c14n.CanonicalizationException;
28 import org.apache.xml.security.c14n.Canonicalizer;
29 import org.apache.xml.security.c14n.InvalidCanonicalizerException;
30 import org.opensaml.SAMLAssertion;
31 import org.opensaml.SAMLAttribute;
32 import org.opensaml.SAMLAttributeStatement;
33 import org.opensaml.SAMLAudienceRestrictionCondition;
34 import org.opensaml.SAMLAuthenticationStatement;
35 import org.opensaml.SAMLConfig;
36 import org.opensaml.SAMLException;
37 import org.opensaml.SAMLNameIdentifier;
38 import org.opensaml.SAMLRequest;
39 import org.opensaml.SAMLResponse;
40 import org.opensaml.SAMLStatement;
41 import org.opensaml.SAMLSubject;
42 import org.opensaml.SAMLSubjectStatement;
43 import org.opensaml.XML;
44 import org.w3c.dom.Document;
45 import org.w3c.dom.Element;
46
47 import edu.internet2.middleware.shibboleth.aa.AAException;
48 import edu.internet2.middleware.shibboleth.common.LocalPrincipal;
49 import edu.internet2.middleware.shibboleth.common.NameIdentifierMappingException;
50 import edu.internet2.middleware.shibboleth.common.RelyingParty;
51 import edu.internet2.middleware.shibboleth.common.ShibbolethConfigurationException;
52 import edu.internet2.middleware.shibboleth.idp.IdPProtocolHandler;
53 import edu.internet2.middleware.shibboleth.idp.IdPProtocolSupport;
54 import edu.internet2.middleware.shibboleth.idp.InvalidClientDataException;
55 import edu.internet2.middleware.shibboleth.metadata.Endpoint;
56 import edu.internet2.middleware.shibboleth.metadata.EntityDescriptor;
57 import edu.internet2.middleware.shibboleth.metadata.SPSSODescriptor;
58
59 /**
60  * <code>ProtocolHandler</code> implementation that responds to ADFS SSO flows as specified in "WS-Federation: Passive
61  * Requestor Interoperability Profiles".
62  * 
63  * @author Walter Hoehn
64  */
65 public class ADFS_SSOHandler extends SSOHandler implements IdPProtocolHandler {
66
67         private static Logger log = Logger.getLogger(ADFS_SSOHandler.class.getName());
68         private static final String WA = "wsignin1.0";
69         private static final String WS_FED_PROTOCOL_ENUM = "http://schemas.xmlsoap.org/ws/2003/07/secext";
70         private static final Collection SUPPORTED_IDENTIFIER_FORMATS = Arrays.asList(new String[]{
71                         "urn:oasis:names:tc:SAML:1.1nameid-format:emailAddress", "http://schemas.xmlsoap.org/claims/UPN",
72                         "http://schemas.xmlsoap.org/claims/CommonName"});
73         private static final String CLAIMS_URI = "http://schemas.xmlsoap.org/claims";
74
75         /**
76          * Required DOM-based constructor.
77          */
78         public ADFS_SSOHandler(Element config) throws ShibbolethConfigurationException {
79
80                 super(config);
81         }
82
83         /*
84          * @see edu.internet2.middleware.shibboleth.idp.IdPResponder.ProtocolHandler#processRequest(javax.servlet.http.HttpServletRequest,
85          *      javax.servlet.http.HttpServletResponse)
86          */
87         public SAMLResponse processRequest(HttpServletRequest request, HttpServletResponse response,
88                         SAMLRequest samlRequest, IdPProtocolSupport support) throws SAMLException, ServletException, IOException {
89
90                 if (request == null) {
91                         log.error("Protocol Handler received a SAML Request, but is unable to handle it.");
92                         throw new SAMLException(SAMLException.RESPONDER, "General error processing request.");
93                 }
94
95                 // Set attributes that are needed by the jsp
96                 // ADFS spec says always send (wa)
97                 request.setAttribute("wa", ADFS_SSOHandler.WA);
98                 // Passthru (wctx) if we get one
99                 if (request.getParameter("wctx") != null && !request.getParameter("wctx").equals("")) {
100                         request.setAttribute("wctx", request.getParameter("wctx"));
101                 }
102
103                 try {
104                         // Ensure that we have the required data from the servlet container
105                         validateEngineData(request);
106                         validateAdfsSpecificData(request);
107
108                         // Get the authN info
109                         String username = support.getIdPConfig().getAuthHeaderName().equalsIgnoreCase("REMOTE_USER") ? request
110                                         .getRemoteUser() : request.getHeader(support.getIdPConfig().getAuthHeaderName());
111                         if ((username == null) || (username.equals(""))) { throw new InvalidClientDataException(
112                                         "Unauthenticated principal. This protocol handler requires that authentication information be "
113                                                         + "provided from the servlet container."); }
114                         LocalPrincipal principal = new LocalPrincipal(username);
115
116                         // Select the appropriate Relying Party configuration for the request
117                         String remoteProviderId = request.getParameter("wtrealm");
118                         log.debug("Remote provider has identified itself as: (" + remoteProviderId + ").");
119                         RelyingParty relyingParty = support.getServiceProviderMapper().getRelyingParty(remoteProviderId);
120
121                         // Grab the metadata for the provider
122                         EntityDescriptor descriptor = support.lookup(relyingParty.getProviderId());
123                         if (descriptor == null) {
124                                 log.info("No metadata found for provider: (" + relyingParty.getProviderId() + ").");
125                                 throw new InvalidClientDataException(
126                                                 "The specified Service Provider is unkown to this Identity Provider.");
127                         }
128
129                         // Make sure we have proper WS-Fed metadata
130                         SPSSODescriptor sp = descriptor.getSPSSODescriptor(ADFS_SSOHandler.WS_FED_PROTOCOL_ENUM);
131                         if (sp == null) {
132                                 log.info("Inappropriate metadata for provider: no WS-Federation binding.");
133                                 throw new InvalidClientDataException(
134                                                 "Unable to communicate with the specified Service Provider via this protocol.");
135                         }
136
137                         // If an acceptance URL was supplied, validate it
138                         String acceptanceURL = request.getParameter("wreply");
139                         if (acceptanceURL != null && !acceptanceURL.equals("")) {
140                                 if (isValidAssertionConsumerURL(sp, acceptanceURL)) {
141                                         log.info("Supplied consumer URL validated for this provider.");
142                                 } else {
143                                         log.error("Assertion consumer service URL (" + acceptanceURL + ") is NOT valid for provider ("
144                                                         + relyingParty.getProviderId() + ").");
145                                         throw new InvalidClientDataException("Invalid assertion consumer service URL.");
146                                 }
147                                 // if none was supplied, pull one from the metadata
148                         } else {
149                                 Endpoint endpoint = sp.getAssertionConsumerServiceManager().getEndpointByBinding(
150                                                 ADFS_SSOHandler.WS_FED_PROTOCOL_ENUM);
151                                 if (endpoint == null || endpoint.getLocation() == null) {
152                                         log.error("No Assertion consumer service URL is available for provider ("
153                                                         + relyingParty.getProviderId() + ") via request the SSO request or the metadata.");
154                                         throw new InvalidClientDataException("Unable to determine assertion consumer service URL.");
155                                 }
156                                 acceptanceURL = endpoint.getLocation();
157                         }
158                         // Needed for the form
159                         request.setAttribute("wreply", acceptanceURL);
160
161                         // Create SAML Name Identifier & Subject
162                         SAMLNameIdentifier nameId;
163                         try {
164                                 nameId = getNameIdentifier(support.getNameMapper(), principal, relyingParty, descriptor);
165                                 // ADFS spec limits which name identifier formats can be used
166                                 if (!ADFS_SSOHandler.SUPPORTED_IDENTIFIER_FORMATS.contains(nameId.getFormat())) {
167                                         log.error("SAML Name Identifier format (" + nameId.getFormat()
168                                                         + ") is inappropriate for use with ADFS provider.");
169                                         throw new SAMLException(
170                                                         "Error converting principal to SAML Name Identifier: Invalid ADFS Name Identifier format.");
171                                 }
172
173                         } catch (NameIdentifierMappingException e) {
174                                 log.error("Error converting principal to SAML Name Identifier: " + e);
175                                 throw new SAMLException("Error converting principal to SAML Name Identifier.", e);
176                         }
177
178                         // ADFS profile requires an authentication method
179                         String authenticationMethod = request.getHeader("SAMLAuthenticationMethod");
180                         if (authenticationMethod == null || authenticationMethod.equals("")) {
181                                 authenticationMethod = relyingParty.getDefaultAuthMethod().toString();
182                                 log.debug("User was authenticated via the default method for this relying party ("
183                                                 + authenticationMethod + ").");
184                         } else {
185                                 log.debug("User was authenticated via the method (" + authenticationMethod + ").");
186                         }
187
188                         SAMLSubject authNSubject = new SAMLSubject(nameId, null, null, null);
189
190                         // We always do POST with ADFS
191                         respondWithPOST(request, response, support, principal, relyingParty, descriptor, acceptanceURL, nameId,
192                                         authenticationMethod, authNSubject);
193
194                 } catch (InvalidClientDataException e) {
195                         throw new SAMLException(SAMLException.RESPONDER, e.getMessage());
196                 } catch (SecurityTokenResponseException e) {
197                         throw new SAMLException(SAMLException.RESPONDER, e.getMessage());
198                 }
199                 return null;
200         }
201
202         private void respondWithPOST(HttpServletRequest request, HttpServletResponse response, IdPProtocolSupport support,
203                         LocalPrincipal principal, RelyingParty relyingParty, EntityDescriptor descriptor, String acceptanceURL,
204                         SAMLNameIdentifier nameId, String authenticationMethod, SAMLSubject authNSubject) throws SAMLException,
205                         IOException, ServletException, SecurityTokenResponseException {
206
207                 // We should always send a single token (SAML assertion)
208                 SAMLAssertion assertion = generateAssertion(request, relyingParty, descriptor, nameId, authenticationMethod,
209                                 getAuthNTime(request), authNSubject);
210
211                 generateAttributes(support, principal, relyingParty, assertion, request);
212
213                 // ADFS spec says assertions should always be signed
214                 support.signAssertions((SAMLAssertion[]) new SAMLAssertion[]{assertion}, relyingParty);
215
216                 // Wrap assertion in security token response and create form
217                 createPOSTForm(request, response, new SecurityTokenResponse(assertion, relyingParty.getProviderId()));
218
219                 // Make transaction log entry
220                 support.getTransactionLog().info(
221                                 "ADFS security token issued to provider (" + relyingParty.getProviderId()
222                                                 + ") on behalf of principal (" + principal.getName() + ").");
223         }
224
225         private void generateAttributes(IdPProtocolSupport support, LocalPrincipal principal, RelyingParty relyingParty,
226                         SAMLAssertion assertion, HttpServletRequest request) throws SAMLException {
227
228                 try {
229                         SAMLAttribute[] attributes = support.getReleaseAttributes(principal, relyingParty, relyingParty
230                                         .getProviderId(), null);
231                         log.info("Found " + attributes.length + " attribute(s) for " + principal.getName());
232
233                         // Bail if we didn't get any attributes
234                         if (attributes == null || attributes.length < 1) {
235                                 log.info("No attributes resolved.");
236                                 return;
237                         }
238
239                         // The ADFS spec recommends that all attributes have this URI, but it doesn't require it
240                         for (int i = 0; i < attributes.length; i++) {
241                                 if (!attributes[i].getNamespace().equals(CLAIMS_URI)) {
242                                         log.warn("It is recommended that all attributes sent via the ADFS SSO handler "
243                                                         + "have a namespace of (" + CLAIMS_URI + ").  The attribute (" + attributes[i].getName()
244                                                         + ") has a namespace of (" + attributes[i].getNamespace() + ").");
245                                 }
246                         }
247
248                         // Reference requested subject
249                         SAMLSubject attrSubject = (SAMLSubject) ((SAMLSubjectStatement) assertion.getStatements().next())
250                                         .getSubject().clone();
251
252                         // ADFS spec says to include authN and attribute statements in the same assertion
253                         log.debug("Merging attributes into existing authn assertion");
254                         assertion.addStatement(new SAMLAttributeStatement(attrSubject, Arrays.asList(attributes)));
255
256                         if (log.isDebugEnabled()) {
257                                 log.debug("Dumping combined Assertion:" + System.getProperty("line.separator") + assertion.toString());
258                         }
259
260                 } catch (AAException e) {
261                         log.error("An error was encountered while generating assertion for attribute push: " + e);
262                         throw new SAMLException(SAMLException.RESPONDER, "General error processing request.");
263                 } catch (CloneNotSupportedException e) {
264                         log.error("An error was encountered while generating assertion for attribute push: " + e);
265                         throw new SAMLException(SAMLException.RESPONDER, "General error processing request.");
266                 }
267         }
268
269         private SAMLAssertion generateAssertion(HttpServletRequest request, RelyingParty relyingParty,
270                         EntityDescriptor descriptor, SAMLNameIdentifier nameId, String authenticationMethod, Date authTime,
271                         SAMLSubject subject) throws SAMLException, IOException {
272
273                 // Bearer method is recommended by the ADFS spec
274                 subject.addConfirmationMethod(SAMLSubject.CONF_BEARER);
275
276                 // ADFS spec requires a single audience of the SP
277                 ArrayList audiences = new ArrayList();
278                 if (relyingParty.getProviderId() != null) {
279                         audiences.add(relyingParty.getProviderId());
280                 }
281                 Vector conditions = new Vector(1);
282                 if (audiences != null && audiences.size() > 0) conditions.add(new SAMLAudienceRestrictionCondition(audiences));
283
284                 // Determine the correct issuer
285                 String issuer = relyingParty.getIdentityProvider().getProviderId();
286
287                 // Create the assertion
288                 // NOTE the ADFS spec says not to specify a locality
289                 SAMLStatement[] statements = {new SAMLAuthenticationStatement(subject, authenticationMethod, authTime, null,
290                                 null, null)};
291
292                 // Package attributes
293                 log.info("Resolving attributes.");
294
295                 SAMLAssertion assertion = new SAMLAssertion(issuer, new Date(System.currentTimeMillis()), new Date(System
296                                 .currentTimeMillis() + 300000), conditions, null, Arrays.asList(statements));
297
298                 if (log.isDebugEnabled()) {
299                         log.debug("Dumping generated Assertion:" + System.getProperty("line.separator") + assertion.toString());
300                 }
301
302                 return assertion;
303         }
304
305         /*
306          * @see edu.internet2.middleware.shibboleth.idp.IdPResponder.ProtocolHandler#getHandlerName()
307          */
308         public String getHandlerName() {
309
310                 return "ADFS SSO Handler";
311         }
312
313         private void validateAdfsSpecificData(HttpServletRequest request) throws InvalidClientDataException {
314
315                 // Required by spec, must have the constant value
316                 if (request.getParameter("wa") == null || !request.getParameter("wa").equals(ADFS_SSOHandler.WA)) { throw new InvalidClientDataException(
317                                 "Invalid data from Service Provider: missing or invalid (wa) parameter."); }
318
319                 // Required by spec
320                 if ((request.getParameter("wtrealm") == null) || (request.getParameter("wtrealm").equals(""))) { throw new InvalidClientDataException(
321                                 "Invalid data from Service Provider:missing or invalid (wtrealm) parameter."); }
322         }
323
324         private static void createPOSTForm(HttpServletRequest req, HttpServletResponse res,
325                         SecurityTokenResponse tokenResponse) throws IOException, ServletException, SecurityTokenResponseException {
326
327                 req.setAttribute("wresult", tokenResponse.toXmlString());
328
329                 if (log.isDebugEnabled()) {
330                         log.debug("Dumping generated Security Token Response:" + System.getProperty("line.separator")
331                                         + tokenResponse.toXmlString());
332                 }
333
334                 RequestDispatcher rd = req.getRequestDispatcher("/adfs.jsp");
335                 rd.forward(req, res);
336         }
337
338         /**
339          * Boolean indication of whethere or not a given assertion consumer URL is valid for a given SP.
340          */
341         private static boolean isValidAssertionConsumerURL(SPSSODescriptor descriptor, String shireURL)
342                         throws InvalidClientDataException {
343
344                 Iterator endpoints = descriptor.getAssertionConsumerServiceManager().getEndpoints();
345                 while (endpoints.hasNext()) {
346                         if (shireURL.equals(((Endpoint) endpoints.next()).getLocation())) { return true; }
347                 }
348                 log.info("Supplied consumer URL not found in metadata.");
349                 return false;
350         }
351
352 }
353
354 class SecurityTokenResponse {
355
356         private static Logger log = Logger.getLogger(SecurityTokenResponse.class.getName());
357         private static SAMLConfig config = SAMLConfig.instance();
358         private static String WS_TRUST_SCHEMA = "http://schemas.xmlsoap.org/ws/2005/02/trust";
359         private static String WS_POLICY_SCHEMA = "http://schemas.xmlsoap.org/ws/2004/09/policy";
360         private static String WS_ADDRESSING_SCHEMA = "http://schemas.xmlsoap.org/ws/2004/08/addressing";
361         private Document response;
362
363         SecurityTokenResponse(SAMLAssertion assertion, String remoteProviderId) throws SecurityTokenResponseException,
364                         SAMLException {
365
366                 response = XML.parserPool.newDocument();
367
368                 // Create root response element
369                 Element root = response.createElementNS(WS_TRUST_SCHEMA, "RequestSecurityTokenResponse");
370                 root.setAttributeNS(XML.XMLNS_NS, "xmlns", WS_TRUST_SCHEMA);
371                 response.appendChild(root);
372
373                 // Tie to remote endpoint
374                 Element appliesTo = response.createElementNS(WS_POLICY_SCHEMA, "AppliesTo");
375                 appliesTo.setAttributeNS(XML.XMLNS_NS, "xmlns", WS_POLICY_SCHEMA);
376                 root.appendChild(appliesTo);
377                 Element endpointRef = response.createElementNS(WS_ADDRESSING_SCHEMA, "EndpointReference");
378                 endpointRef.setAttributeNS(XML.XMLNS_NS, "xmlns", WS_ADDRESSING_SCHEMA);
379                 appliesTo.appendChild(endpointRef);
380                 Element address = response.createElementNS(WS_ADDRESSING_SCHEMA, "Address");
381                 address.appendChild(response.createTextNode(remoteProviderId));
382                 endpointRef.appendChild(address);
383
384                 // Add security token
385                 Element token = response.createElementNS(WS_TRUST_SCHEMA, "RequestedSecurityToken");
386
387                 token.appendChild(assertion.toDOM(response));
388                 root.appendChild(token);
389
390         }
391
392         public byte[] toBase64() throws SecurityTokenResponseException {
393
394                 try {
395                         Canonicalizer canonicalizier = Canonicalizer.getInstance(Canonicalizer.ALGO_ID_C14N_EXCL_OMIT_COMMENTS);
396                         byte[] canonicalized = canonicalizier.canonicalizeSubtree(response, config
397                                         .getProperty("org.opensaml.inclusive-namespace-prefixes"));
398
399                         return Base64.encodeBase64Chunked(canonicalized);
400                 } catch (InvalidCanonicalizerException e) {
401                         log.error("Error Canonicalizing Security Token Response: " + e);
402                         throw new SecurityTokenResponseException(e.getMessage());
403                 }
404
405                 catch (CanonicalizationException e) {
406                         log.error("Error Canonicalizing Security Token Response: " + e);
407                         throw new SecurityTokenResponseException(e.getMessage());
408                 }
409         }
410
411         public String toXmlString() throws SecurityTokenResponseException {
412
413                 try {
414                         Canonicalizer canonicalizier = Canonicalizer.getInstance(Canonicalizer.ALGO_ID_C14N_EXCL_OMIT_COMMENTS);
415                         byte[] canonicalized = canonicalizier.canonicalizeSubtree(response, config
416                                         .getProperty("org.opensaml.inclusive-namespace-prefixes"));
417                         return new String(canonicalized);
418
419                 } catch (InvalidCanonicalizerException e) {
420                         log.error("Error Canonicalizing Security Token Response: " + e);
421                         throw new SecurityTokenResponseException(e.getMessage());
422                 }
423
424                 catch (CanonicalizationException e) {
425                         log.error("Error Canonicalizing Security Token Response: " + e);
426                         throw new SecurityTokenResponseException(e.getMessage());
427                 }
428         }
429
430 }
431
432 class SecurityTokenResponseException extends Exception {
433
434         SecurityTokenResponseException(String message) {
435
436                 super(message);
437         }
438 }