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