/* * Copyright 2001-2005 Internet2 * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package gov.nih.nci.cagrid.opensaml; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.HashMap; import java.util.Iterator; import java.util.Map; import java.util.Stack; import java.util.Map.Entry; import javax.xml.XMLConstants; import javax.xml.namespace.QName; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; import javax.xml.transform.Source; import javax.xml.transform.sax.SAXSource; import javax.xml.transform.stream.StreamSource; import javax.xml.validation.Schema; import javax.xml.validation.SchemaFactory; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.Node; import org.w3c.dom.Text; import org.xml.sax.EntityResolver; import org.xml.sax.ErrorHandler; import org.xml.sax.InputSource; import org.xml.sax.SAXException; import org.xml.sax.SAXParseException; /** * Utility classes for XML constants and optimizations * * @author Scott Cantor, Howard Gilbert * @created January 2, 2002 */ public class XML { /** OpenSAML configuration */ protected SAMLConfig config = SAMLConfig.instance(); /** XML core namespace */ public final static String XML_NS = "http://www.w3.org/XML/1998/namespace"; /** XML namespace for xmlns attributes */ public final static String XMLNS_NS = "http://www.w3.org/2000/xmlns/"; /** XML Schema Instance namespace */ public final static String XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"; /** XML Schema Instance namespace */ public final static String XSD_NS = "http://www.w3.org/2001/XMLSchema"; /** OpenSAML XML namespace */ public final static String OPENSAML_NS = "http://www.opensaml.org"; /** SAML XML namespace */ public final static String SAML_NS = "urn:oasis:names:tc:SAML:1.0:assertion"; /** SAML protocol XML namespace */ public final static String SAMLP_NS = "urn:oasis:names:tc:SAML:1.0:protocol"; /** SAML 1.x Metadata Profile protocol indicators and namespace */ public final static String SAML10_PROTOCOL_ENUM = SAMLP_NS; public final static String SAML11_PROTOCOL_ENUM = "urn:oasis:names:tc:SAML:1.1:protocol"; public final static String SAML_ARTIFACT_SOURCEID = "urn:oasis:names:tc:SAML:profiles:v1metadata"; /** XML Signature namespace */ public final static String XMLSIG_NS = "http://www.w3.org/2000/09/xmldsig#"; /** SOAP 1.1 Envelope XML namespace */ public final static String SOAP11ENV_NS = "http://schemas.xmlsoap.org/soap/envelope/"; /** XML core schema identifier */ public final static String XML_SCHEMA_ID = "xml.xsd"; /** SAML XML Schema Identifier */ public final static String SAML_SCHEMA_ID = "cs-sstc-schema-assertion-01.xsd"; /** SAML protocol XML Schema Identifier */ public final static String SAMLP_SCHEMA_ID = "cs-sstc-schema-protocol-01.xsd"; /** SAML 1.1 XML Schema Identifier */ public final static String SAML11_SCHEMA_ID = "cs-sstc-schema-assertion-1.1.xsd"; /** SAML 1.1 protocol XML Schema Identifier */ public final static String SAMLP11_SCHEMA_ID = "cs-sstc-schema-protocol-1.1.xsd"; /** XML Signature Schema Identifier */ public final static String XMLSIG_SCHEMA_ID = "xmldsig-core-schema.xsd"; /** SOAP 1.1 Envelope Schema Identifier */ public final static String SOAP11ENV_SCHEMA_ID = "soap-envelope.xsd"; private static Logger log = LoggerFactory.getLogger(XML.class); /** A global object to manage a pool of custom DOM parsers */ public static ParserPool parserPool = new ParserPool(); /** * A "safe" null/empty check for strings. * * @param s * The string to check * @return true iff the string is null or length zero */ public static boolean isEmpty(String s) { return (s == null || s.length() == 0); } /** * A "safe" assignment function for strings that blocks the empty string * * @param s * The string to check * @return s iff the string is non-empty or else null */ public static String assign(String s) { return (s != null && s.length() > 0) ? s.trim() : null; } /** * Compares two strings for equality, allowing for nulls * * @param s1 * The first operand * @param s2 * The second operand * * @return true iff both are null or both are non-null and the same strng * value */ public static boolean safeCompare(String s1, String s2) { if (s1 == null || s2 == null) return s1 == s2; else return s1.equals(s2); } /** * Shortcut for checking a DOM element node's namespace and local name * * @param e * An element to compare against * @param ns * An XML namespace to compare * @param localName * A local name to compare * @return true iff the element's local name and namespace match the * parameters */ public static boolean isElementNamed(Element e, String ns, String localName) { return (e != null && safeCompare(ns, e.getNamespaceURI()) && safeCompare( localName, e.getLocalName())); } /** * Gets the first child Element of the node, skipping any Text nodes such as * whitespace. * * @param n * The parent in which to search for children * @return The first child Element of n, or null if none */ public static Element getFirstChildElement(Node n) { Node child = n.getFirstChild(); while (child != null && child.getNodeType() != Node.ELEMENT_NODE) child = child.getNextSibling(); if (child != null) return (Element) child; else return null; } /** * Gets the last child Element of the node, skipping any Text nodes such as * whitespace. * * @param n * The parent in which to search for children * @return The last child Element of n, or null if none */ public static Element getLastChildElement(Node n) { Node child = n.getLastChild(); while (child != null && child.getNodeType() != Node.ELEMENT_NODE) child = child.getPreviousSibling(); if (child != null) return (Element) child; else return null; } /** * Gets the first child Element of the node of the given name, skipping any * Text nodes such as whitespace. * * @param n * The parent in which to search for children * @param ns * The namespace URI of the element to locate * @param localName * The local name of the element to locate * @return The first child Element of n with the specified name, or null if * none */ public static Element getFirstChildElement(Node n, String ns, String localName) { Element e = getFirstChildElement(n); while (e != null && !isElementNamed(e, ns, localName)) e = getNextSiblingElement(e); return e; } /** * Gets the last child Element of the node of the given name, skipping any * Text nodes such as whitespace. * * @param n * The parent in which to search for children * @param ns * The namespace URI of the element to locate * @param localName * The local name of the element to locate * @return The last child Element of n with the specified name, or null if * none */ public static Element getLastChildElement(Node n, String ns, String localName) { Element e = getLastChildElement(n); while (e != null && !isElementNamed(e, ns, localName)) e = getPreviousSiblingElement(e); return e; } /** * Gets the next sibling Element of the node, skipping any Text nodes such * as whitespace. * * @param n * The sibling to start with * @return The next sibling Element of n, or null if none */ public static Element getNextSiblingElement(Node n) { Node sib = n.getNextSibling(); while (sib != null && sib.getNodeType() != Node.ELEMENT_NODE) sib = sib.getNextSibling(); if (sib != null) return (Element) sib; else return null; } /** * Gets the previous sibling Element of the node, skipping any Text nodes * such as whitespace. * * @param n * The sibling to start with * @return The previous sibling Element of n, or null if none */ public static Element getPreviousSiblingElement(Node n) { Node sib = n.getPreviousSibling(); while (sib != null && sib.getNodeType() != Node.ELEMENT_NODE) sib = sib.getPreviousSibling(); if (sib != null) return (Element) sib; else return null; } /** * Gets the next sibling Element of the node of the given name, skipping any * Text nodes such as whitespace. * * @param n * The sibling to start with * @param ns * The namespace URI of the element to locate * @param localName * The local name of the element to locate * @return The next sibling Element of n with the specified name, or null if * none */ public static Element getNextSiblingElement(Node n, String ns, String localName) { Element e = getNextSiblingElement(n); while (e != null && !isElementNamed(e, ns, localName)) e = getNextSiblingElement(e); return e; } /** * Gets the previous sibling Element of the node of the given name, skipping * any Text nodes such as whitespace. * * @param n * The sibling to start with * @param ns * The namespace URI of the element to locate * @param localName * The local name of the element to locate * @return The previous sibling Element of n with the specified name, or * null if none */ public static Element getPreviousSiblingElement(Node n, String ns, String localName) { Element e = getPreviousSiblingElement(n); while (e != null && !isElementNamed(e, ns, localName)) e = getPreviousSiblingElement(e); return e; } /** * Builds a QName from a QName-valued attribute by evaluating it * * @param e * The element containing the attribute * @param namespace * The namespace of the attribute * @param name * The local name of the attribute * @return A QName containing the attribute value as a namespace/local name * pair. */ public static QName getQNameAttribute(Element e, String namespace, String name) { String qval = XML.assign(e.getAttributeNS(namespace, name)); if (qval == null) return null; return new QName(getNamespaceForQName(qval, e), qval.substring(qval .indexOf(':') + 1)); } /** * Builds a QName from a QName-valued text node by evaluating it * * @param t * The text node containing the QName value * @return A QName containing the text node value as a namespace/local name * pair. */ public static QName getQNameTextNode(Text t) { String qval = XML.assign(t.getNodeValue()); Node n = t.getParentNode(); if (qval == null || n == null || n.getNodeType() != Node.ELEMENT_NODE) return null; return new QName(getNamespaceForQName(qval, (Element) n), qval.substring(qval.indexOf(':') + 1)); } /** * Gets the XML namespace URI that is mapped to the prefix of a QName, in * the context of the DOM element e * * @param qname * The QName value to map a prefix from * @param e * The DOM element in which to calculate the prefix binding * @return The XML namespace URI mapped to qname's prefix in the context of * e */ public static String getNamespaceForQName(String qname, Element e) { // Determine the QName prefix. String prefix = null; if (qname != null && qname.indexOf(':') >= 0) prefix = qname.substring(0, qname.indexOf(':')); return getNamespaceForPrefix(prefix, e); } /** * Gets the XML namespace URI that is mapped to the specified prefix, in the * context of the DOM element e * * @param prefix * The namespace prefix to map * @param e * The DOM element in which to calculate the prefix binding * @return The XML namespace URI mapped to prefix in the context of e */ public static String getNamespaceForPrefix(String prefix, Element e) { return e.lookupNamespaceURI(prefix); /* * Node n = e; String ns = null; * * if (prefix != null) { if (prefix.equals("xml")) return XML.XML_NS; * else if (prefix.equals("xmlns")) return XML.XMLNS_NS; } * * while ((ns == null || ns.length()==0) && n != null && n.getNodeType() * == Node.ELEMENT_NODE) { ns = * ((Element)n).getAttributeNS(XML.XMLNS_NS,(prefix!=null) ? prefix : * "xmlns"); n = n.getParentNode(); } return ns; */ } /** * Nested class that provides XML parsers as a pooled resource * * @author Scott Cantor, Howard Gilbert * @created January 15, 2002 */ public static class ParserPool implements ErrorHandler, EntityResolver { /** OpenSAML configuration */ protected SAMLConfig config = SAMLConfig.instance(); // Stacks of DocumentBuilder parsers keyed by the Schema they support private Map /* <Schema,Stack> */pools = new HashMap(); // The stack of non-schema-validating parsers private Stack unparsedpool = new Stack(); // Resolution of extension schemas keyed by XML namespace private Map /* <String,EntityResolver> */extensions = new HashMap(); /* * The 1.0 and 1.1 SAML XSD files use the same namespace but they are * not compatible. The 1.0 schema is "broken" and should not be used, * but it is included and made available if someone needs to send or * expects to receive 1.0 formatted traffic. * * The default Default schema is 1.1. This can be overridden by the * hosting application if additional namespace information will be * injected into the SAML elements. For example, Shibboleth must * override the default and supply its own Schema object constructed * from the same files plus at least the shibboleth.xsd file containing * the definition of namespace elements that override types of the * AttributeValue element. * * The default is assigned at static class initialization. It can be * changed at any time, before or after calls have been made and the * pools are partially filled. The default applies only to calls that do * not specify their own Schema object. When the default changes, the * old default Schema simply becomes a pool of parsers that can be used * if you provide that Schema as an explicit argument. */ private Schema defaultSchema = null; // The default schema (one of the // following) private Schema schemaSAML10 = null; // The SAML 1.0 Standard schema private Schema schemaSAML11 = null; // The SAML 1.1 Standard schema // (default) /** * Original method to install a custom schema. Use setDefaultSchemas * instead to maintain support for SAML 1.0 and 1.1. * * @param schema * @deprecated */ public synchronized void setDefaultSchema(Schema schema) { this.defaultSchema = schema; } /** * Directly installs a custom schema. You must supply both a SAML 1.0 * and a SAML 1.1 schema object. * * @param schema10 * The schemas to use when handling SAML 1.0 * @param schema11 * The schemas to use when handling SAML 1.1 */ public synchronized void setDefaultSchemas(Schema schema10, Schema schema11) { this.schemaSAML10 = schema10; this.schemaSAML11 = schema11; if (SAMLConfig.instance().getBooleanProperty( "gov.nih.nci.cagrid.opensaml.compatibility-mode")) { defaultSchema = schemaSAML10; } else { defaultSchema = schemaSAML11; // This is the expected default } } public synchronized Schema getDefaultSchema() { return defaultSchema; } public synchronized Schema getSchemaSAML10() { return schemaSAML10; } public synchronized Schema getSchemaSAML11() { return schemaSAML11; } /* * The JAXP factory is set up once and is then used to create parsers in * the parser pool. Access to this field must be synchronized, and is in * ParserPool.get() */ private DocumentBuilderFactory dbf = null; /** * Constructor for the ParserPool object * * <p> * To demonstrate the technology, the current version of this code * creates both 1.0 and 1.1 Schema objects. However, it then selects * only one of the two to use. Future code could refine this and * maintain two pools of parsers. */ public ParserPool() { // Build a parser factory and the default schema set. dbf = DocumentBuilderFactory.newInstance(); dbf.setNamespaceAware(true); try { dbf.setFeature( "http://apache.org/xml/features/validation/schema/normalized-value", false); } catch (ParserConfigurationException e) { log.warn("Unable to turn off data normalization in parser, supersignatures may fail with Xerces-J: " + e); } registerSchemas(null); /* * The DocumentBuilderFactory is almost ready. The last step will be * to assign a particular Schema to it before obtaining each parser. * The parser will then go in the pool associated with that Schema * object. This is done at runtime in the get method. */ } /** * Registers one or more extension schemas in the default schema set. * This relieves SAML applications from managing their own JAXP schema * objects and enables dual compatibility with SAML 1.0 and 1.1 * <p> * Note that you <b>must</b> insure that any dependencies are specified * ahead of the schemas that require them, because they must be loaded * by the SchemaFactory before they are required. * * @param exts * A map of EntityResolver interfaces keyed by "systemId" to * enable the SAML runtime to obtain the schema instances * anytime required */ public synchronized void registerSchemas( Map /* <String,EntityResolver> */exts) { // First merge the new set into the maintained set. if (exts != null) extensions.putAll(exts); /* * Create a JAXP 1.3 Schema object from an array of open files. * There is no EntityResolver or ResourceResolver, so the list must * be complete (no dependencies on XSD files not in the list. Also, * to compile correctly, an XSD file must appear in the list before * another XSD that depends on (imports) it. */ SchemaFactory factory = SchemaFactory .newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI); ArrayList sources = new ArrayList(); sources.add(new StreamSource(XML.class .getResourceAsStream("/schemas/" + XML_SCHEMA_ID), XML_SCHEMA_ID)); sources.add(new StreamSource(XML.class .getResourceAsStream("/schemas/" + XML.XMLSIG_SCHEMA_ID), XML.XMLSIG_SCHEMA_ID)); sources.add(new StreamSource( XML.class.getResourceAsStream("/schemas/" + XML.SOAP11ENV_SCHEMA_ID), XML.SOAP11ENV_SCHEMA_ID)); sources.add(new StreamSource(XML.class .getResourceAsStream("/schemas/" + SAML_SCHEMA_ID), SAML_SCHEMA_ID)); sources.add(new StreamSource(XML.class .getResourceAsStream("/schemas/" + SAMLP_SCHEMA_ID), SAMLP_SCHEMA_ID)); for (Iterator i = extensions.entrySet().iterator(); i.hasNext();) { Entry entry = (Entry) i.next(); try { sources.add(new SAXSource(((EntityResolver) entry .getValue()).resolveEntity(null, (String) entry.getKey()))); } catch (SAXException e) { log.error("Unable to obtain extension schema (" + entry.getKey() + "): " + e); } catch (IOException e) { log.error("Unable to obtain extension schema (" + entry.getKey() + "): " + e); } } try { schemaSAML10 = factory.newSchema((Source[]) sources .toArray(new Source[0])); } catch (SAXException e) { log.error("Unable to parse SAML 1.0 Schemas: " + e); } // Note: I would like to close the InputStream objects, but the API // is silent on this. To be safe, I must assume they no longer // belong // to me but have been transferred to JAXP. /* * Now do it again. We need new InputStream objects because the * previous streams have been read to the end of file. */ sources.clear(); sources.add(new StreamSource(XML.class .getResourceAsStream("/schemas/" + XML_SCHEMA_ID), XML_SCHEMA_ID)); sources.add(new StreamSource(XML.class .getResourceAsStream("/schemas/" + XML.XMLSIG_SCHEMA_ID), XML.XMLSIG_SCHEMA_ID)); sources.add(new StreamSource( XML.class.getResourceAsStream("/schemas/" + XML.SOAP11ENV_SCHEMA_ID), XML.SOAP11ENV_SCHEMA_ID)); sources.add(new StreamSource(XML.class .getResourceAsStream("/schemas/" + SAML11_SCHEMA_ID), SAML11_SCHEMA_ID)); sources.add(new StreamSource(XML.class .getResourceAsStream("/schemas/" + SAMLP11_SCHEMA_ID), SAMLP11_SCHEMA_ID)); for (Iterator i = extensions.entrySet().iterator(); i.hasNext();) { Entry entry = (Entry) i.next(); try { sources.add(new SAXSource(((EntityResolver) entry .getValue()).resolveEntity(null, (String) entry.getKey()))); } catch (SAXException e) { log.error("Unable to obtain extension schema (" + entry.getKey() + "): " + e); } catch (IOException e) { log.error("Unable to obtain extension schema (" + entry.getKey() + "): " + e); } } try { schemaSAML11 = factory.newSchema((Source[]) sources .toArray(new Source[0])); } catch (SAXException e) { log.error("Unable to parse SAML 1.1 Schemas: " + e); } // A property can be used to select icky 1.0 syntax if (SAMLConfig.instance().getBooleanProperty( "gov.nih.nci.cagrid.opensaml.compatibility-mode")) { defaultSchema = schemaSAML10; } else { defaultSchema = schemaSAML11; // This is the expected default } } /** * Get a DOM parser suitable for our task * * @param schema * JAXP 1.3 Schema object (or null for no XSD) * @return A DOM parser ready to use * @exception SAMLException * Raised if a system error prevents a parser from being * created */ public synchronized DocumentBuilder get(Schema schema) throws SAMLException { DocumentBuilder p = null; Stack pool; if (schema != null) { pool = (Stack) pools.get(schema); if (pool == null) { pool = new Stack(); pools.put(schema, pool); } } else { pool = unparsedpool; // Parser with no xsd validation } if (pool.empty()) { // Build a parser to order. try { dbf.setSchema(schema); // null for no validation, or a // Schema object p = dbf.newDocumentBuilder(); p.setErrorHandler(this); p.setEntityResolver(this); // short-circuits URI resolution } catch (ParserConfigurationException e) { log.error("Unable to obtain usable XML parser from environment"); throw new SAMLException( "Unable to obtain usable XML parser from environment", e); } } else p = (DocumentBuilder) pool.pop(); return p; } /** * Get a DocumentBuilder for the default Schema * * <p> * Note: This uses the default (probably SAML 1.1) Schema. To get an * non-schema-validating parser, call "get(null)". * </p> * * @return Document Builder * @throws SAMLException * can't create a DocumentBuilder */ public DocumentBuilder get() throws SAMLException { return get(getDefaultSchema()); } /** * Parses a document using a pooled parser with the proper settings * * @param in * A stream containing the content to be parsed * @param schema * Schema object or null * @return The DOM document resulting from the parse * @exception SAMLException * Raised if a parser is unavailable * @exception SAXException * Raised if a parsing error occurs * @exception java.io.IOException * Raised if an I/O error occurs */ public Document parse(InputSource in, Schema schema) throws SAMLException, SAXException, java.io.IOException { DocumentBuilder p = get(schema); try { Document doc = p.parse(in); return doc; } finally { put(p); } } /** * Short form of parse to support legacy callers * * <p> * This version is not preferred. If the caller converts the InputStream * to an InputSource, then it can append a file name as the systemId. * Here we only get the InputStream and create an InputSource with no * identifier to be used in logging or generating error messages. * * @param in * InputStream of XML to be parsed * @return DOM Document * @exception SAMLException * Raised if a parser is unavailable * @exception SAXException * Raised if a parsing error occurs * @exception java.io.IOException * Raised if an I/O error occurs */ public Document parse(InputStream in) throws SAMLException, SAXException, java.io.IOException { return parse(new InputSource(in), getDefaultSchema()); } /** * Parses a document using a pooled parser with the proper settings * * @param systemId * The URI to parse * @return The DOM document resulting from the parse * @exception SAMLException * Raised if a parser is unavailable * @exception SAXException * Raised if a parsing error occurs * @exception java.io.IOException * Raised if an I/O error occurs */ public Document parse(String systemId, Schema schema) throws SAMLException, SAXException, java.io.IOException { DocumentBuilder p = get(schema); try { Document doc = p.parse(new InputSource(systemId)); return doc; } finally { put(p); } } /** * Legacy version of parse where the default Schema is implied * * @param systemId * URI to be parsed, becomes systemId of InputSource * @return DOM Document * @exception SAMLException * Raised if a parser is unavailable * @exception SAXException * Raised if a parsing error occurs * @exception java.io.IOException * Raised if an I/O error occurs */ public Document parse(String systemId) throws SAMLException, SAXException, java.io.IOException { return parse(systemId, getDefaultSchema()); } /** * Builds a new DOM document * * <p> * In JAXP, you get a new empty DOM document from a DocumentBuilder. * There is no evidence that the Schema is attached to the DOM, so it * doesn't matter what pool to use. * * @return An empty DOM document */ public Document newDocument() { DocumentBuilder p = null; try { p = get(); } catch (SAMLException e) { // Configuration error, no XML support. Return null?? // Throw RuntimeException?? return null; } Document doc = p.newDocument(); put(p); return doc; } /** * Return a parser to the pool * * @param p * Description of Parameter */ public synchronized void put(DocumentBuilder p) { Schema schema = p.getSchema(); if (schema == null) { unparsedpool.push(p); } else { Stack pool = (Stack) pools.get(schema); pool.push(p); } } /** * Called by parser if a fatal error is detected, does nothing * * @param exception * Describes the error * @exception SAXException * Can be raised to indicate an explicit error */ public void fatalError(SAXParseException e) throws SAXException { throw e; } /** * Called by parser if an error is detected, currently just throws e * * @param e * Description of Parameter * @exception SAXParseException * Can be raised to indicate an explicit error */ public void error(SAXParseException e) throws SAXParseException { throw e; } /** * Called by parser if a warning is issued, currently logs the condition * * @param e * Describes the warning * @exception SAXParseException * Can be raised to indicate an explicit error */ public void warning(SAXParseException e) throws SAXParseException { log.warn("Parser warning: line = " + e.getLineNumber() + " : uri = " + e.getSystemId()); log.warn("Parser warning (root cause): " + e.getMessage()); } public InputSource resolveEntity(String publicId, String systemId) throws SAXException, IOException { /* * During parsing, this should not be called with a systemId * corresponding to any known externally resolvable entity. It * prevents "accidental" resolution of external entities via URI * resolution. Network based retrieval of resources is NOT allowable * and should really be something the parser can block globally. We * also can't return null, because that signals URI resolution. So * what we return is a dummy source to shortcut and fail any such * attempts. */ return new InputSource(); // Hopefully this will fail the parser and // not be treated as null. } } }