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