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