    001/*
    002    Licensed to the Apache Software Foundation (ASF) under one
    003    or more contributor license agreements.  See the NOTICE file
    004    distributed with this work for additional information
    005    regarding copyright ownership.  The ASF licenses this file
    006    to you under the Apache License, Version 2.0 (the
    007    "License"); you may not use this file except in compliance
    008    with the License.  You may obtain a copy of the License at
    009
    010       http://www.apache.org/licenses/LICENSE-2.0
    011
    012    Unless required by applicable law or agreed to in writing,
    013    software distributed under the License is distributed on an
    014    "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
    015    KIND, either express or implied.  See the License for the
    016    specific language governing permissions and limitations
    017    under the License.
    018 */
    019package org.apache.wiki.auth.authorize;
    020
    021import org.apache.logging.log4j.LogManager;
    022import org.apache.logging.log4j.Logger;
    023import org.apache.wiki.InternalWikiException;
    024import org.apache.wiki.api.core.Engine;
    025import org.apache.wiki.api.core.Session;
    026import org.jdom2.Document;
    027import org.jdom2.Element;
    028import org.jdom2.JDOMException;
    029import org.jdom2.Namespace;
    030import org.jdom2.filter.Filters;
    031import org.jdom2.input.SAXBuilder;
    032import org.jdom2.input.sax.XMLReaders;
    033import org.jdom2.xpath.XPathFactory;
    034import org.xml.sax.EntityResolver;
    035import org.xml.sax.InputSource;
    036import org.xml.sax.SAXException;
    037
    038import javax.servlet.http.HttpServletRequest;
    039import java.io.IOException;
    040import java.net.URL;
    041import java.security.Principal;
    042import java.util.Arrays;
    043import java.util.List;
    044import java.util.Properties;
    045import java.util.Set;
    046import java.util.stream.Collectors;
    047
    048
    049/**
    050 * Authorizes users by delegating role membership checks to the servlet container. In addition to implementing
    051 * methods for the <code>Authorizer</code> interface, this class also provides a convenience method
    052 * {@link #isContainerAuthorized()} that queries the web application descriptor to determine if the container
    053 * manages authorization.
    054 *
    055 * @since 2.3
    056 */
    057public class WebContainerAuthorizer implements WebAuthorizer  {
    058
    059    private static final String J2EE_SCHEMA_25_NAMESPACE = "http://xmlns.jcp.org/xml/ns/javaee";
    060
    061    private static final Logger LOG = LogManager.getLogger( WebContainerAuthorizer.class );
    062
    063    protected Engine m_engine;
    064
    065    /**
    066     * A lazily-initialized array of Roles that the container knows about. These
    067     * are parsed from JSPWiki's <code>web.xml</code> web application
    068     * deployment descriptor. If this file cannot be read for any reason, the
    069     * role list will be empty. This is a hack designed to get around the fact
    070     * that we have no direct way of querying the web container about which
    071     * roles it manages.
    072     */
    073    protected Role[] m_containerRoles = new Role[0];
    074
    075    /** Lazily-initialized boolean flag indicating whether the web container protects JSPWiki resources. */
    076    protected boolean m_containerAuthorized;
    077
    078    private Document m_webxml;
    079
    080    /**
    081     * Constructs a new instance of the WebContainerAuthorizer class.
    082     */
    083    public WebContainerAuthorizer()
    084    {
    085        super();
    086    }
    087
    088    /**
    089     * Initializes the authorizer for.
    090     * @param engine the current wiki engine
    091     * @param props the wiki engine initialization properties
    092     */
    093    @Override
    094    public void initialize( final Engine engine, final Properties props ) {
    095        m_engine = engine;
    096        m_containerAuthorized = false;
    097
    098        // FIXME: Error handling here is not very verbose
    099        try {
    100            m_webxml = getWebXml();
    101            if( m_webxml != null ) {
    102                // Add the JEE schema namespace
    103                m_webxml.getRootElement().setNamespace( Namespace.getNamespace( J2EE_SCHEMA_25_NAMESPACE ) );
    104
    105                m_containerAuthorized = isConstrained( "/Delete.jsp", Role.ALL ) && isConstrained( "/Login.jsp", Role.ALL );
    106            }
    107            if( m_containerAuthorized ) {
    108                m_containerRoles = getRoles( m_webxml );
    109                LOG.info( "JSPWiki is using container-managed authentication." );
    110            } else {
    111                LOG.info( "JSPWiki is using custom authentication." );
    112            }
    113        } catch( final IOException e ) {
    114            LOG.error( "Initialization failed: ", e );
    115            throw new InternalWikiException( e.getClass().getName() + ": " + e.getMessage(), e );
    116        } catch( final JDOMException e ) {
    117            LOG.error( "Malformed XML in web.xml", e );
    118            throw new InternalWikiException( e.getClass().getName() + ": " + e.getMessage(), e );
    119        }
    120
    121        if( m_containerRoles.length > 0 ) {
    122            final String roles = Arrays.stream(m_containerRoles).map(containerRole -> containerRole + " ").collect(Collectors.joining());
    123            LOG.info( " JSPWiki determined the web container manages these roles: " + roles );
    124        }
    125        LOG.info( "Authorizer WebContainerAuthorizer initialized successfully." );
    126    }
    127
    128    /**
    129     * Determines whether a user associated with an HTTP request possesses
    130     * a particular role. This method simply delegates to
    131     * {@link javax.servlet.http.HttpServletRequest#isUserInRole(String)}
    132     * by converting the Principal's name to a String.
    133     * @param request the HTTP request
    134     * @param role the role to check
    135     * @return <code>true</code> if the user is considered to be in the role, <code>false</code> otherwise
    136     */
    137    @Override
    138    public boolean isUserInRole( final HttpServletRequest request, final Principal role ) {
    139        return request.isUserInRole( role.getName() );
    140    }
    141
    142    /**
    143     * Determines whether the Subject associated with a Session is in a
    144     * particular role. This method takes two parameters: the Session
    145     * containing the subject and the desired role ( which may be a Role or a
    146     * Group). If either parameter is <code>null</code>, this method must
    147     * return <code>false</code>.
    148     * This method simply examines the Session subject to see if it
    149     * possesses the desired Principal. We assume that the method
    150     * {@link org.apache.wiki.ui.WikiServletFilter#doFilter(javax.servlet.ServletRequest, javax.servlet.ServletResponse, javax.servlet.FilterChain)}
    151     * previously executed, and that it has set the Session
    152     * subject correctly by logging in the user with the various login modules,
    153     * in particular {@link org.apache.wiki.auth.login.WebContainerLoginModule}}.
    154     * This is definitely a hack,
    155     * but it eliminates the need for Session to keep dangling
    156     * references to the last WikiContext hanging around, just
    157     * so we can look up the HttpServletRequest.
    158     *
    159     * @param session the current Session
    160     * @param role the role to check
    161     * @return <code>true</code> if the user is considered to be in the role, <code>false</code> otherwise
    162     * @see org.apache.wiki.auth.Authorizer#isUserInRole(org.apache.wiki.api.core.Session, java.security.Principal)
    163     */
    164    @Override
    165    public boolean isUserInRole( final Session session, final Principal role ) {
    166        if( session == null || role == null ) {
    167            return false;
    168        }
    169        return session.hasPrincipal( role );
    170    }
    171
    172    /**
    173     * Looks up and returns a Role Principal matching a given String. If the
    174     * Role does not match one of the container Roles identified during
    175     * initialization, this method returns <code>null</code>.
    176     * @param role the name of the Role to retrieve
    177     * @return a Role Principal, or <code>null</code>
    178     * @see org.apache.wiki.auth.Authorizer#initialize(Engine, Properties)
    179     */
    180    @Override
    181    public Principal findRole( final String role ) {
    182        return Arrays.stream(m_containerRoles).filter(containerRole -> containerRole.getName().equals(role)).findFirst().map(containerRole -> containerRole).orElse(null);
    183    }
    184
    185    /**
    186     * <p>
    187     * Protected method that identifies whether a particular webapp URL is
    188     * constrained to a particular Role. The resource is considered constrained
    189     * if:
    190     * </p>
    191     * <ul>
    192     * <li>the web application deployment descriptor contains a
    193     * <code>security-constraint</code> with a child
    194     * <code>web-resource-collection/url-pattern</code> element matching the
    195     * URL, <em>and</em>:</li>
    196     * <li>this constraint also contains an
    197     * <code>auth-constraint/role-name</code> element equal to the supplied
    198     * Role's <code>getName()</code> method. If the supplied Role is Role.ALL,
    199     * it matches all roles</li>
    200     * </ul>
    201     * @param url the web resource
    202     * @param role the role
    203     * @return <code>true</code> if the resource is constrained to the role,
    204     *         <code>false</code> otherwise
    205     */
    206    public boolean isConstrained( final String url, final Role role ) {
    207        final Element root = m_webxml.getRootElement();
    208        final Namespace jeeNs = Namespace.getNamespace( "j", J2EE_SCHEMA_25_NAMESPACE );
    209
    210        // Get all constraints that have our URL pattern
    211        // (Note the crazy j: prefix to denote the jee schema)
    212        final String constrainsSelector = "//j:web-app/j:security-constraint[j:web-resource-collection/j:url-pattern=\"" + url + "\"]";
    213        final List< Element > constraints = XPathFactory.instance()
    214                                                        .compile( constrainsSelector, Filters.element(), null, jeeNs )
    215                                                        .evaluate( root );
    216
    217        // Get all constraints that match our Role pattern
    218        final String rolesSelector = "//j:web-app/j:security-constraint[j:auth-constraint/j:role-name=\"" + role.getName() + "\"]";
    219        final List< Element > roles = XPathFactory.instance()
    220                                                  .compile( rolesSelector, Filters.element(), null, jeeNs )
    221                                                  .evaluate( root );
    222
    223        // If we can't find either one, we must not be constrained
    224        if(constraints.isEmpty()) {
    225            return false;
    226        }
    227
    228        // Shortcut: if the role is ALL, we are constrained
    229        if( role.equals( Role.ALL ) ) {
    230            return true;
    231        }
    232
    233        // If no roles, we must not be constrained
    234        if(roles.isEmpty()) {
    235            return false;
    236        }
    237
    238        // If a constraint is contained in both lists, we must be constrained
    239        return constraints.stream().anyMatch(constraint -> roles.stream().anyMatch(constraint::equals));
    240    }
    241
    242    /**
    243     * Returns <code>true</code> if the web container is configured to protect
    244     * certain JSPWiki resources by requiring authentication. Specifically, this
    245     * method parses JSPWiki's web application descriptor (<code>web.xml</code>)
    246     * and identifies whether the string representation of
    247     * {@link org.apache.wiki.auth.authorize.Role#AUTHENTICATED} is required
    248     * to access <code>/Delete.jsp</code> and <code>LoginRedirect.jsp</code>.
    249     * If the administrator has uncommented the large
    250     * <code>&lt;security-constraint&gt;</code> section of <code>web.xml</code>,
    251     * this will be true. This is admittedly an indirect way to go about it, but
    252     * it should be an accurate test for default installations, and also in 99%
    253     * of customized installations.
    254     *
    255     * @return <code>true</code> if the container protects resources, <code>false</code> otherwise
    256     */
    257    public boolean isContainerAuthorized()
    258    {
    259        return m_containerAuthorized;
    260    }
    261
    262    /**
    263     * Returns an array of role Principals this Authorizer knows about.
    264     * This method will return an array of Role objects corresponding to
    265     * the logical roles enumerated in the <code>web.xml</code>.
    266     * This method actually returns a defensive copy of an internally stored
    267     * array.
    268     *
    269     * @return an array of Principals representing the roles
    270     */
    271    @Override
    272    public Principal[] getRoles()
    273    {
    274        return m_containerRoles.clone();
    275    }
    276
    277    /**
    278     * Protected method that extracts the roles from JSPWiki's web application
    279     * deployment descriptor. Each Role is constructed by using the String
    280     * representation of the Role, for example
    281     * <code>new Role("Administrator")</code>.
    282     * @param webxml the web application deployment descriptor
    283     * @return an array of Role objects
    284     */
    285    protected Role[] getRoles( final Document webxml ) {
    286        final Set<Role> roles;
    287        final Element root = webxml.getRootElement();
    288        final Namespace jeeNs = Namespace.getNamespace( "j", J2EE_SCHEMA_25_NAMESPACE );
    289
    290        // Get roles referred to by constraints
    291        final String constrainsSelector = "//j:web-app/j:security-constraint/j:auth-constraint/j:role-name";
    292        final List< Element > constraints = XPathFactory.instance()
    293                                                        .compile( constrainsSelector, Filters.element(), null, jeeNs )
    294                                                        .evaluate( root );
    295        roles = constraints.stream().map(Element::getTextTrim).map(Role::new).collect(Collectors.toSet());
    296
    297        // Get all defined roles
    298        final String rolesSelector = "//j:web-app/j:security-role/j:role-name";
    299        final List< Element > nodes = XPathFactory.instance()
    300                                                  .compile( rolesSelector, Filters.element(), null, jeeNs )
    301                                                  .evaluate( root );
    302        for( final Element node : nodes ) {
    303            final String role = node.getTextTrim();
    304            roles.add( new Role( role ) );
    305        }
    306
    307        return roles.toArray( new Role[0] );
    308    }
    309
    310    /**
    311     * Returns an {@link org.jdom2.Document} representing JSPWiki's web
    312     * application deployment descriptor. The document is obtained by calling
    313     * the servlet context's <code>getResource()</code> method and requesting
    314     * <code>/WEB-INF/web.xml</code>. For non-servlet applications, this
    315     * method calls this class'
    316     * {@link ClassLoader#getResource(java.lang.String)} and requesting
    317     * <code>WEB-INF/web.xml</code>.
    318     * @return the descriptor
    319     * @throws IOException if the deployment descriptor cannot be found or opened
    320     * @throws JDOMException if the deployment descriptor cannot be parsed correctly
    321     */
    322    protected Document getWebXml() throws JDOMException, IOException {
    323        final URL url;
    324        final SAXBuilder builder = new SAXBuilder();
    325        builder.setXMLReaderFactory( XMLReaders.NONVALIDATING );
    326        builder.setEntityResolver( new LocalEntityResolver() );
    327        final Document doc;
    328        if ( m_engine.getServletContext() == null ) {
    329            final ClassLoader cl = WebContainerAuthorizer.class.getClassLoader();
    330            url = cl.getResource( "WEB-INF/web.xml" );
    331            if( url != null ) {
    332                LOG.info( "Examining {}", url.toExternalForm() );
    333            }
    334        } else {
    335            url = m_engine.getServletContext().getResource( "/WEB-INF/web.xml" );
    336            if( url != null )
    337                LOG.info( "Examining " + url.toExternalForm() );
    338        }
    339        if( url == null ) {
    340            throw new IOException("Unable to find web.xml for processing.");
    341        }
    342
    343        LOG.debug( "Processing web.xml at {}", url.toExternalForm() );
    344        doc = builder.build( url );
    345        return doc;
    346    }
    347
    348    /**
    349     * <p>XML entity resolver that redirects resolution requests by JDOM, JAXP and
    350     * other XML parsers to locally-cached copies of the resources. Local
    351     * resources are stored in the <code>WEB-INF/dtd</code> directory.</p>
    352     * <p>For example, Sun Microsystem's DTD for the webapp 2.3 specification is normally
    353     * kept at <code>http://java.sun.com/dtd/web-app_2_3.dtd</code>. The
    354     * local copy is stored at <code>WEB-INF/dtd/web-app_2_3.dtd</code>.</p>
    355     */
    356    public class LocalEntityResolver implements EntityResolver {
    357        /**
    358         * Returns an XML input source for a requested external resource by
    359         * reading the resource instead from local storage. The local resource path
    360         * is <code>WEB-INF/dtd</code>, plus the file name of the requested
    361         * resource, minus the non-filename path information.
    362         *
    363         * @param publicId the public ID, such as <code>-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN</code>
    364         * @param systemId the system ID, such as <code>http://java.sun.com/dtd/web-app_2_3.dtd</code>
    365         * @return the InputSource containing the resolved resource
    366         * @see org.xml.sax.EntityResolver#resolveEntity(java.lang.String, java.lang.String)
    367         * @throws SAXException if the resource cannot be resolved locally
    368         * @throws IOException if the resource cannot be opened
    369         */
    370        @Override
    371        public InputSource resolveEntity( final String publicId, final String systemId ) throws SAXException, IOException {
    372            final String file = systemId.substring( systemId.lastIndexOf( '/' ) + 1 );
    373            final URL url;
    374            if( m_engine.getServletContext() == null ) {
    375                final ClassLoader cl = WebContainerAuthorizer.class.getClassLoader();
    376                url = cl.getResource( "WEB-INF/dtd/" + file );
    377            } else {
    378                url = m_engine.getServletContext().getResource( "/WEB-INF/dtd/" + file );
    379            }
    380
    381            if( url != null ) {
    382                final InputSource is = new InputSource( url.openStream() );
    383                LOG.debug( "Resolved systemID={} using local file {}", systemId, url );
    384                return is;
    385            }
    386
    387            //
    388            //  Let's fall back to default behaviour of the container, and let's
    389            //  also let the user know what is going on.  This caught me by surprise
    390            //  while running JSPWiki on an unconnected laptop...
    391            //
    392            //  The DTD needs to be resolved and read because it contains things like entity definitions...
    393            //
    394            LOG.info("Please note: There are no local DTD references in /WEB-INF/dtd/{}; falling back to default" +
    395                     " behaviour. This may mean that the XML parser will attempt to connect to the internet to find the" +
    396                     " DTD. If you are running JSPWiki locally in an unconnected network, you might want to put the DTD " +
    397                     " files in place to avoid nasty UnknownHostExceptions.", file );
    398
    399
    400            // Fall back to default behaviour
    401            return null;
    402        }
    403    }
    404
    405}