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