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><security-constraint></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}