/*
* Copyright 2017 OmniFaces
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*/
package org.omnifaces.config;
import static java.lang.String.format;
import static java.util.Collections.emptySet;
import static java.util.Collections.unmodifiableSet;
import static java.util.logging.Level.SEVERE;
import static org.omnifaces.util.Faces.getServletContext;
import static org.omnifaces.util.Faces.hasContext;
import static org.omnifaces.util.Utils.isEmpty;
import static org.omnifaces.util.Utils.isNumber;
import static org.omnifaces.util.Xml.createDocument;
import static org.omnifaces.util.Xml.getNodeList;
import static org.omnifaces.util.Xml.getTextContent;
import java.io.IOException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Logger;
import javax.faces.context.FacesContext;
import javax.faces.webapp.FacesServlet;
import javax.servlet.Filter;
import javax.servlet.ServletContext;
import javax.servlet.ServletContextListener;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathExpressionException;
import javax.xml.xpath.XPathFactory;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;
/**
* <p>
* This configuration enum parses the <code>/WEB-INF/web.xml</code> and all <code>/META-INF/web-fragment</code> files
* found in the classpath and offers methods to obtain information from them which is not available by the standard
* Servlet API.
*
* <h3>Usage</h3>
* <p>
* Some examples:
* <pre>
* // Get the <welcome-file-list> (which are essentially path-relative filenames which needs to be served when a folder is requested).
* List<String> welcomeFiles = WebXml.INSTANCE.getWelcomeFiles();
* </pre>
* <pre>
* // Get a mapping of all error page locations by exception type (a key of null represents the default error page location, if any).
* Map<Class<Throwable>, String> errorPageLocations = WebXml.INSTANCE.getErrorPageLocations();
* </pre>
* <pre>
* // Get the <form-login-page> (which is a context-relative URL to the login page of FORM based authentication).
* String formLoginPage = WebXml.INSTANCE.getFormLoginPage();
* </pre>
* <pre>
* // Get a mapping of all <security-constraint> URL patterns and associated roles.
* Map<String, Set<String>> securityConstraints = WebXml.INSTANCE.getSecurityConstraints();
* </pre>
* <pre>
* // Check if access to certain (context-relative) URL is allowed for the given role based on <security-constraint>.
* boolean accessAllowed = WebXml.INSTANCE.isAccessAllowed("/admin.xhtml", "admin");
* </pre>
* <pre>
* // Get web.xml configured session timeout (in seconds).
* int sessionTimeout = WebXml.INSTANCE.getSessionTimeout();
* </pre>
*
* @author Bauke Scholtz
* @since 1.2
*/
public enum WebXml {
// Enum singleton -------------------------------------------------------------------------------------------------
/**
* Returns the lazily loaded enum singleton instance.
* <p>
* Note: if this is needed in e.g. a {@link Filter} which is called before the {@link FacesServlet} is invoked,
* then it won't work if the <code>INSTANCE</code> hasn't been referenced before. Since JSF installs a special
* "init" {@link FacesContext} during startup, one option for doing this initial referencing is in a
* {@link ServletContextListener}. The data this enum encapsulates will then be available even where there is no
* {@link FacesContext} available. If there's no other option, then you need to manually invoke
* {@link #init(ServletContext)} whereby you pass the desired {@link ServletContext}.
*/
INSTANCE;
// Private constants ----------------------------------------------------------------------------------------------
private static final Logger logger = Logger.getLogger(WebXml.class.getName());
private static final String WEB_XML = "/WEB-INF/web.xml";
private static final String WEB_FRAGMENT_XML = "META-INF/web-fragment.xml";
private static final String XPATH_WELCOME_FILE =
"welcome-file-list/welcome-file";
private static final String XPATH_EXCEPTION_TYPE =
"error-page/exception-type";
private static final String XPATH_LOCATION =
"location";
private static final String XPATH_ERROR_PAGE_500_LOCATION =
"error-page[error-code=500]/location";
private static final String XPATH_ERROR_PAGE_DEFAULT_LOCATION =
"error-page[not(error-code) and not(exception-type)]/location";
private static final String XPATH_FORM_LOGIN_PAGE =
"login-config[auth-method='FORM']/form-login-config/form-login-page";
private static final String XPATH_FORM_ERROR_PAGE =
"login-config[auth-method='FORM']/form-login-config/form-error-page";
private static final String XPATH_SECURITY_CONSTRAINT =
"security-constraint";
private static final String XPATH_WEB_RESOURCE_URL_PATTERN =
"web-resource-collection/url-pattern";
private static final String XPATH_AUTH_CONSTRAINT =
"auth-constraint";
private static final String XPATH_AUTH_CONSTRAINT_ROLE_NAME =
"auth-constraint/role-name";
private static final String XPATH_SESSION_TIMEOUT =
"session-config/session-timeout";
private static final String ERROR_NOT_INITIALIZED =
"WebXml is not initialized yet. Please use #init(ServletContext) method to manually initialize it.";
private static final String ERROR_URL_MUST_START_WITH_SLASH =
"URL must start with '/': '%s'";
private static final String LOG_INITIALIZATION_ERROR =
"WebXml failed to initialize. Perhaps your web.xml contains a typo?";
// Properties -----------------------------------------------------------------------------------------------------
private final AtomicBoolean initialized = new AtomicBoolean();
private List<String> welcomeFiles;
private Map<Class<Throwable>, String> errorPageLocations;
private String formLoginPage;
private String formErrorPage;
private Map<String, Set<String>> securityConstraints;
private int sessionTimeout;
// Init -----------------------------------------------------------------------------------------------------------
/**
* Perform automatic initialization whereby the servlet context is obtained from the faces context.
* TODO: obtain it from CDI instead a la FacesConfigXml.
*/
private void init() {
if (!initialized.get() && hasContext()) {
init(getServletContext());
}
}
/**
* Perform manual initialization with the given servlet context, if not null and not already initialized yet.
* @param servletContext The servlet context to obtain the web.xml from.
* @return The current {@link WebXml} instance, initialized and all.
*/
public WebXml init(ServletContext servletContext) {
if (servletContext != null && !initialized.getAndSet(true)) {
try {
Element webXml = loadWebXml(servletContext).getDocumentElement();
XPath xpath = XPathFactory.newInstance().newXPath();
welcomeFiles = parseWelcomeFiles(webXml, xpath);
errorPageLocations = parseErrorPageLocations(webXml, xpath);
formLoginPage = parseFormLoginPage(webXml, xpath);
formErrorPage = parseFormErrorPage(webXml, xpath);
securityConstraints = parseSecurityConstraints(webXml, xpath);
sessionTimeout = parseSessionTimeout(webXml, xpath);
}
catch (Exception e) {
initialized.set(false);
logger.log(SEVERE, LOG_INITIALIZATION_ERROR, e);
throw new UnsupportedOperationException(e);
}
}
return this;
}
// Actions --------------------------------------------------------------------------------------------------------
/**
* Find for the given exception the right error page location. Exception types are matched as per Servlet 3.0
* specification 10.9.2 with the exception that the given exception is already unwrapped:
* <ul>
* <li>Make a pass through all specific exception types. If a match is found in the exception class hierarchy,
* use its location. The closest match in the class hierarchy wins.
* <li>Else use the default error page location, which can be either the java.lang.Throwable or HTTP 500 or
* default one.
* </ul>
* @param exception The exception to find the error page location for.
* @return The right error page location for the given exception.
*/
public String findErrorPageLocation(Throwable exception) {
checkInitialized();
String location = null;
for (Class<?> cls = exception.getClass(); cls != null && location == null; cls = cls.getSuperclass()) {
location = errorPageLocations.get(cls);
}
return (location == null) ? errorPageLocations.get(null) : location;
}
/**
* Returns <code>true</code> if access to the given URL is allowed for the given role. URL patterns are matched as
* per Servlet 3.0 specification 12.1:
* <ul>
* <li>Make a first pass through all URL patterns. If an exact match is found, then check the role on it.
* <li>Else make a recursive pass through all prefix URL patterns, stepping down the URL one directory at a time,
* trying to find the longest path match. If it is found, then check the role on it.
* <li>Else if the last segment in the URL path contains an extension, then make a last pass through all suffix
* URL patterns. If a match is found, then check the role on it.
* <li>Else assume it as unprotected resource and return <code>true</code>.
* </ul>
* @param url URL to be checked for access by the given role. It must start with '/' and be context-relative.
* @param role Role to be checked for access to the given URL.
* @return <code>true</code> if access to the given URL is allowed for the given role, otherwise <code>false</code>.
* @throws NullPointerException If given URL is null.
* @throws IllegalArgumentException If given URL does not start with '/'.
* @since 1.4
*/
public boolean isAccessAllowed(String url, String role) {
checkInitialized();
if (url.charAt(0) != ('/')) {
throw new IllegalArgumentException(format(ERROR_URL_MUST_START_WITH_SLASH, url));
}
String uri = url;
if (url.length() > 1 && url.charAt(url.length() - 1) == '/') {
uri = url.substring(0, url.length() - 1); // Trim trailing slash.
}
Set<String> roles = findExactMatchRoles(uri);
if (roles.isEmpty()) {
roles = findPrefixMatchRoles(uri);
}
if (roles.isEmpty()) {
roles = findSuffixMatchRoles(uri);
}
return isRoleMatch(roles, role);
}
private Set<String> findExactMatchRoles(String url) {
for (Entry<String, Set<String>> entry : securityConstraints.entrySet()) {
if (isExactMatch(entry.getKey(), url)) {
return entry.getValue();
}
}
return emptySet();
}
private Set<String> findPrefixMatchRoles(String url) {
String urlMatch = "";
for (String path = url; !path.isEmpty(); path = path.substring(0, path.lastIndexOf('/'))) {
Set<String> roles = null;
for (Entry<String, Set<String>> entry : securityConstraints.entrySet()) {
if (urlMatch.length() < entry.getKey().length() && isPrefixMatch(entry.getKey(), path)) {
urlMatch = entry.getKey();
roles = entry.getValue();
}
}
if (roles != null) {
return roles;
}
}
return emptySet();
}
private Set<String> findSuffixMatchRoles(String url) {
if (url.contains(".")) {
for (Entry<String, Set<String>> entry : securityConstraints.entrySet()) {
if (isSuffixMatch(url, entry.getKey())) {
return entry.getValue();
}
}
}
return emptySet();
}
private static boolean isExactMatch(String urlPattern, String url) {
return url.equals(urlPattern.endsWith("/*") ? urlPattern.substring(0, urlPattern.length() - 2) : urlPattern);
}
private static boolean isPrefixMatch(String urlPattern, String url) {
return urlPattern.endsWith("/*") && (url + "/").startsWith(urlPattern.substring(0, urlPattern.length() - 1));
}
private static boolean isSuffixMatch(String urlPattern, String url) {
return urlPattern.startsWith("*.") && url.endsWith(urlPattern.substring(1));
}
private static boolean isRoleMatch(Set<String> roles, String role) {
return roles.isEmpty() || roles.contains(role) || (role != null && roles.contains("*"));
}
// Getters --------------------------------------------------------------------------------------------------------
/**
* Returns a list of all welcome files.
* @return A list of all welcome files.
* @since 1.4
*/
public List<String> getWelcomeFiles() {
checkInitialized();
return welcomeFiles;
}
/**
* Returns a mapping of all error page locations by exception type. The default location is identified by
* <code>null</code> key.
* @return A mapping of all error page locations by exception type.
*/
public Map<Class<Throwable>, String> getErrorPageLocations() {
checkInitialized();
return errorPageLocations;
}
/**
* Returns the location of the FORM authentication login page, or <code>null</code> if it is not defined.
* @return The location of the FORM authentication login page, or <code>null</code> if it is not defined.
*/
public String getFormLoginPage() {
checkInitialized();
return formLoginPage;
}
/**
* Returns the location of the FORM authentication error page, or <code>null</code> if it is not defined.
* @return The location of the FORM authentication error page, or <code>null</code> if it is not defined.
* @since 1.8
*/
public String getFormErrorPage() {
checkInitialized();
return formErrorPage;
}
/**
* Returns a mapping of all security constraint URL patterns and the associated roles in the declared order. If the
* roles is <code>null</code>, then it means that no auth constraint is been set (i.e. the resource is publicly
* accessible). If the roles is empty, then it means that an empty auth constraint is been set (i.e. the resource
* is in no way accessible).
* @return A mapping of all security constraint URL patterns and the associated roles in the declared order.
* @since 1.4
*/
public Map<String, Set<String>> getSecurityConstraints() {
checkInitialized();
return securityConstraints;
}
/**
* Returns the configured session timeout in minutes, or <code>-1</code> if it is not defined.
* @return The configured session timeout in minutes, or <code>-1</code> if it is not defined.
* @since 1.7
*/
public int getSessionTimeout() {
checkInitialized();
return sessionTimeout;
}
private void checkInitialized() {
// This init() call is performed here instead of in constructor, because WebLogic loads this enum as a CDI
// managed bean (in spite of having a VetoAnnotatedTypeExtension) which in turn implicitly invokes the enum
// constructor and thus causes an init while JSF context isn't fully initialized and thus the faces context
// isn't available yet. Perhaps it's fixed in newer WebLogic versions.
init();
if (!initialized.get()) {
throw new IllegalStateException(ERROR_NOT_INITIALIZED);
}
}
// Helpers --------------------------------------------------------------------------------------------------------
/**
* Load, merge and return all <code>web.xml</code> and <code>web-fragment.xml</code> files found in the classpath
* into a single {@link Document}.
*/
private static Document loadWebXml(ServletContext context) throws IOException, SAXException {
List<URL> webXmlURLs = new ArrayList<>();
webXmlURLs.add(context.getResource(WEB_XML));
webXmlURLs.addAll(Collections.list(Thread.currentThread().getContextClassLoader().getResources(WEB_FRAGMENT_XML)));
return createDocument(webXmlURLs);
}
/**
* Create and return a list of all welcome files.
*/
private static List<String> parseWelcomeFiles(Element webXml, XPath xpath) throws XPathExpressionException {
NodeList welcomeFileList = getNodeList(webXml, xpath, XPATH_WELCOME_FILE);
List<String> welcomeFiles = new ArrayList<>(welcomeFileList.getLength());
for (int i = 0; i < welcomeFileList.getLength(); i++) {
welcomeFiles.add(getTextContent(welcomeFileList.item(i)));
}
return Collections.unmodifiableList(welcomeFiles);
}
/**
* Create and return a mapping of all error page locations by exception type found in the given document.
* @throws ClassNotFoundException
*/
@SuppressWarnings("unchecked") // For the cast on Class<Throwable>.
private static Map<Class<Throwable>, String> parseErrorPageLocations(Element webXml, XPath xpath) throws XPathExpressionException, ClassNotFoundException {
Map<Class<Throwable>, String> errorPageLocations = new HashMap<>();
NodeList exceptionTypes = getNodeList(webXml, xpath, XPATH_EXCEPTION_TYPE);
for (int i = 0; i < exceptionTypes.getLength(); i++) {
Node node = exceptionTypes.item(i);
Class<Throwable> exceptionClass = (Class<Throwable>) Class.forName(getTextContent(node));
String exceptionLocation = xpath.compile(XPATH_LOCATION).evaluate(node.getParentNode()).trim();
Class<Throwable> key = (exceptionClass == Throwable.class) ? null : exceptionClass;
if (!errorPageLocations.containsKey(key)) {
errorPageLocations.put(key, exceptionLocation);
}
}
if (!errorPageLocations.containsKey(null)) {
String defaultLocation = xpath.compile(XPATH_ERROR_PAGE_500_LOCATION).evaluate(webXml).trim();
if (isEmpty(defaultLocation)) {
defaultLocation = xpath.compile(XPATH_ERROR_PAGE_DEFAULT_LOCATION).evaluate(webXml).trim();
}
if (!isEmpty(defaultLocation)) {
errorPageLocations.put(null, defaultLocation);
}
}
return Collections.unmodifiableMap(errorPageLocations);
}
/**
* Return the location of the FORM authentication login page.
*/
private static String parseFormLoginPage(Element webXml, XPath xpath) throws XPathExpressionException {
String formLoginPage = xpath.compile(XPATH_FORM_LOGIN_PAGE).evaluate(webXml).trim();
return isEmpty(formLoginPage) ? null : formLoginPage;
}
/**
* Return the location of the FORM authentication error page.
*/
private static String parseFormErrorPage(Element webXml, XPath xpath) throws XPathExpressionException {
String formErrorPage = xpath.compile(XPATH_FORM_ERROR_PAGE).evaluate(webXml).trim();
return isEmpty(formErrorPage) ? null : formErrorPage;
}
/**
* Create and return a mapping of all security constraint URL patterns and the associated roles.
*/
private static Map<String, Set<String>> parseSecurityConstraints(Element webXml, XPath xpath) throws XPathExpressionException {
Map<String, Set<String>> securityConstraints = new LinkedHashMap<>();
NodeList constraints = getNodeList(webXml, xpath, XPATH_SECURITY_CONSTRAINT);
for (int i = 0; i < constraints.getLength(); i++) {
Node constraint = constraints.item(i);
Set<String> roles = emptySet();
NodeList auth = getNodeList(constraint, xpath, XPATH_AUTH_CONSTRAINT);
if (auth.getLength() > 0) {
NodeList authRoles = getNodeList(constraint, xpath, XPATH_AUTH_CONSTRAINT_ROLE_NAME);
roles = new HashSet<>(authRoles.getLength());
for (int j = 0; j < authRoles.getLength(); j++) {
roles.add(getTextContent(authRoles.item(j)));
}
}
NodeList urlPatterns = getNodeList(constraint, xpath, XPATH_WEB_RESOURCE_URL_PATTERN);
for (int j = 0; j < urlPatterns.getLength(); j++) {
String urlPattern = getTextContent(urlPatterns.item(j));
Set<String> allRoles = securityConstraints.get(urlPattern);
if (allRoles != null) {
allRoles = new HashSet<>(allRoles);
allRoles.addAll(roles);
}
else {
allRoles = roles;
}
securityConstraints.put(urlPattern, unmodifiableSet(allRoles));
}
}
return Collections.unmodifiableMap(securityConstraints);
}
/**
* Return the configured session timeout in minutes, or <code>-1</code> if it is not defined.
*/
private static int parseSessionTimeout(Element webXml, XPath xpath) throws XPathExpressionException {
String sessionTimeout = xpath.compile(XPATH_SESSION_TIMEOUT).evaluate(webXml).trim();
return isNumber(sessionTimeout) ? Integer.parseInt(sessionTimeout) : -1;
}
}