/* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you 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.apache.isis.core.webapp; import java.io.IOException; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Set; import java.util.regex.Pattern; import javax.servlet.Filter; import javax.servlet.FilterChain; import javax.servlet.FilterConfig; import javax.servlet.ServletException; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import com.google.common.base.Function; import com.google.common.base.Splitter; import com.google.common.collect.Collections2; import com.google.common.collect.Lists; import org.apache.isis.core.commons.authentication.AuthenticationSession; import org.apache.isis.core.commons.factory.InstanceUtil; import org.apache.isis.core.commons.lang.StringExtensions; import org.apache.isis.core.metamodel.specloader.validator.MetaModelInvalidException; import org.apache.isis.core.runtime.system.context.IsisContext; import org.apache.isis.core.runtime.system.session.IsisSessionFactory; import org.apache.isis.core.webapp.auth.AuthenticationSessionStrategy; import org.apache.isis.core.webapp.auth.AuthenticationSessionStrategyDefault; import org.apache.isis.core.webapp.content.ResourceCachingFilter; public class IsisSessionFilter implements Filter { /** * Recommended standard init parameter key for filters and servlets to * lookup an implementation of {@link AuthenticationSessionStrategy}. */ public static final String AUTHENTICATION_SESSION_STRATEGY_KEY = "authenticationSessionStrategy"; /** * Default value for {@link #AUTHENTICATION_SESSION_STRATEGY_KEY} if not specified. */ public static final String AUTHENTICATION_SESSION_STRATEGY_DEFAULT = AuthenticationSessionStrategyDefault.class.getName(); /** * Init parameter key for backward compatibility; if logonPage set then * assume 'restricted' handling. */ public static final String LOGON_PAGE_KEY = "logonPage"; /** * Init parameter key for what should be done if no session was found. * * <p> * Valid values are: * <ul> * <li>unauthorized - issue a 401 response. * <li>basicAuthChallenge - issue a basic auth 401 challenge. The idea here * is that the configured logon strategy should handle the next request * <li>restricted - allow access but only to a restricted (comma-separated) * list of paths. Access elsewhere should be redirected to the first of * these paths * <li>continue - allow the request to continue (eg if there is no security * requirements) * </ul> */ public static final String WHEN_NO_SESSION_KEY = "whenNoSession"; /** * Which URLs to ignore (eg <code>/restful/swagger</code> so that swagger specs can be accessed from the swagger-ui) */ public static final String PASS_THRU_KEY = "passThru"; /** * Init parameter key to read the restricted list of paths (if * {@link #WHEN_NO_SESSION_KEY} is for {@link WhenNoSession#RESTRICTED}). * * <p> * The servlets mapped to these paths are expected to be able to deal with * there being no session. Typically they will be logon pages. */ public static final String RESTRICTED_KEY = "restricted"; /** * Init parameter key to redirect to if an exception occurs. */ public static final String REDIRECT_TO_ON_EXCEPTION_KEY = "redirectToOnException"; /** * Init parameter key for which extensions should be ignored (typically, * mappings for other viewers within the webapp context). * * <p> * It can also be used to specify ignored static resources (though putting * the {@link ResourceCachingFilter} first in the <tt>web.xml</tt> * accomplishes the same thing). * * <p> * The value is expected as a comma separated list. */ public static final String IGNORE_EXTENSIONS_KEY = "ignoreExtensions"; private static final Function<String, Pattern> STRING_TO_PATTERN = new Function<String, Pattern>() { @Override public Pattern apply(final String input) { return Pattern.compile(".*\\." + input); } }; /** * Somewhat hacky, add this to the query */ public static final String QUERY_STRING_FORCE_LOGOUT = "__isis_force_logout"; private String passThru; static void redirect(final HttpServletRequest httpRequest, final HttpServletResponse httpResponse, final String redirectTo) throws IOException { httpResponse.sendRedirect(StringExtensions.combinePath(httpRequest.getContextPath(), redirectTo)); } public enum WhenNoSession { UNAUTHORIZED("unauthorized") { @Override public void handle(final IsisSessionFilter filter, final HttpServletRequest httpRequest, final HttpServletResponse httpResponse, final FilterChain chain) throws IOException, ServletException { httpResponse.sendError(401); } }, BASIC_AUTH_CHALLENGE("basicAuthChallenge") { @Override public void handle(final IsisSessionFilter filter, final HttpServletRequest httpRequest, final HttpServletResponse httpResponse, final FilterChain chain) throws IOException, ServletException { httpResponse.setHeader("WWW-Authenticate", "Basic realm=\"Apache Isis\""); httpResponse.sendError(401); } }, AUTO("auto") { @Override public void handle(final IsisSessionFilter filter, final HttpServletRequest httpRequest, final HttpServletResponse httpResponse, final FilterChain chain) throws IOException, ServletException { if(fromWebBrowser(httpRequest)) { httpResponse.setHeader("WWW-Authenticate", "Basic realm=\"Apache Isis\""); } httpResponse.sendError(401); } private boolean fromWebBrowser(final HttpServletRequest httpRequest) { String accept = httpRequest.getHeader("Accept"); return accept.contains("text/html"); } }, /** * the destination servlet is expected to know that there will be no open session, and handle the case appropriately */ CONTINUE("continue") { @Override public void handle(final IsisSessionFilter filter, final HttpServletRequest httpRequest, final HttpServletResponse httpResponse, final FilterChain chain) throws IOException, ServletException { chain.doFilter(httpRequest, httpResponse); } }, /** * Allow access to a restricted list of URLs (else redirect to the first of that list of URLs) */ RESTRICTED("restricted") { @Override public void handle(final IsisSessionFilter filter, final HttpServletRequest httpRequest, final HttpServletResponse httpResponse, final FilterChain chain) throws IOException, ServletException { if (filter.restrictedPaths.contains(httpRequest.getServletPath())) { chain.doFilter(httpRequest, httpResponse); return; } redirect(httpRequest, httpResponse, filter.restrictedPaths.get(0)); } }; private final String initParamValue; private WhenNoSession(final String initParamValue) { this.initParamValue = initParamValue; } public static WhenNoSession lookup(final String whenNoSessionStr) { for (final WhenNoSession wns : values()) { if (wns.initParamValue.equals(whenNoSessionStr)) { return wns; } } throw new IllegalStateException("require an init-param of '" + WHEN_NO_SESSION_KEY + "', taking a value of " + WhenNoSession.values()); } public abstract void handle(IsisSessionFilter filter, HttpServletRequest httpRequest, HttpServletResponse httpResponse, FilterChain chain) throws IOException, ServletException; } private AuthenticationSessionStrategy authSessionStrategy; private List<String> restrictedPaths; private WhenNoSession whenNotAuthenticated; private String redirectToOnException; private Collection<Pattern> ignoreExtensions; // ///////////////////////////////////////////////////////////////// // init, destroy // ///////////////////////////////////////////////////////////////// @Override public void init(final FilterConfig config) throws ServletException { authSessionStrategy = lookup(config.getInitParameter(AUTHENTICATION_SESSION_STRATEGY_KEY)); lookupWhenNoSession(config); lookupPassThru(config); lookupRedirectToOnException(config); lookupIgnoreExtensions(config); } /** * Public visibility so can also be used by servlets. */ public static AuthenticationSessionStrategy lookup(String authLookupStrategyClassName) { if (authLookupStrategyClassName == null) { authLookupStrategyClassName = AUTHENTICATION_SESSION_STRATEGY_DEFAULT; } return (AuthenticationSessionStrategy) InstanceUtil.createInstance(authLookupStrategyClassName); } private void lookupWhenNoSession(final FilterConfig config) { final String whenNoSessionStr = config.getInitParameter(WHEN_NO_SESSION_KEY); // backward compatibility final String logonPage = config.getInitParameter(LOGON_PAGE_KEY); if (logonPage != null) { if (whenNoSessionStr != null) { throw new IllegalStateException(String.format( "The init-param '%s' is only provided for backwards compatibility; " + "remove if the init-param '%s' has been specified", LOGON_PAGE_KEY, WHEN_NO_SESSION_KEY)); } else { // default whenNotAuthenticated and allow access through to the logonPage whenNotAuthenticated = WhenNoSession.RESTRICTED; this.restrictedPaths = Lists.newArrayList(logonPage); return; } } whenNotAuthenticated = WhenNoSession.lookup(whenNoSessionStr); if (whenNotAuthenticated == WhenNoSession.RESTRICTED) { final String restrictedPathsStr = config.getInitParameter(RESTRICTED_KEY); if (restrictedPathsStr == null) { throw new IllegalStateException(String.format("Require an init-param of '%s' key to be set.", RESTRICTED_KEY)); } this.restrictedPaths = Lists.newArrayList(Splitter.on(",").split(restrictedPathsStr)); } } private void lookupPassThru(final FilterConfig config) { this.passThru = config.getInitParameter(PASS_THRU_KEY); } private void lookupRedirectToOnException(final FilterConfig config) { redirectToOnException = config.getInitParameter(REDIRECT_TO_ON_EXCEPTION_KEY); } private void lookupIgnoreExtensions(final FilterConfig config) { ignoreExtensions = Collections.unmodifiableCollection(parseIgnorePatterns(config)); } private static Collection<Pattern> parseIgnorePatterns(final FilterConfig config) { final String ignoreExtensionsStr = config.getInitParameter(IGNORE_EXTENSIONS_KEY); if (ignoreExtensionsStr != null) { final List<String> ignoreExtensions = Lists.newArrayList(Splitter.on(",").split(ignoreExtensionsStr)); return Collections2.transform(ignoreExtensions, STRING_TO_PATTERN); } return Lists.newArrayList(); } @Override public void destroy() { } // ///////////////////////////////////////////////////////////////// // doFilter // ///////////////////////////////////////////////////////////////// @Override public void doFilter(final ServletRequest request, final ServletResponse response, final FilterChain chain) throws IOException, ServletException { ensureMetamodelIsValid(); final HttpServletRequest httpServletRequest = (HttpServletRequest) request; final HttpServletResponse httpServletResponse = (HttpServletResponse) response; final IsisSessionFactory sessionFactory = isisSessionFactoryFrom(httpServletRequest); try { final String queryString = httpServletRequest.getQueryString(); if (queryString != null && queryString.contains(QUERY_STRING_FORCE_LOGOUT)) { authSessionStrategy.invalidate(httpServletRequest, httpServletResponse); return; } if (requestIsIgnoreExtension(this, httpServletRequest) || ResourceCachingFilter.isCachedResource(httpServletRequest)) { chain.doFilter(request, response); return; } if(requestIsPassThru(httpServletRequest)) { chain.doFilter(request, response); return; } // authenticate final AuthenticationSession authSession = authSessionStrategy.lookupValid(httpServletRequest, httpServletResponse); if (authSession != null) { authSessionStrategy.bind(httpServletRequest, httpServletResponse, authSession); sessionFactory.openSession(authSession); chain.doFilter(request, response); return; } try { whenNotAuthenticated.handle(this, httpServletRequest, httpServletResponse, chain); } catch (final RuntimeException | IOException | ServletException ex) { // in case the destination servlet cannot cope, but we've // been told to redirect elsewhere if (redirectToOnException != null) { redirect(httpServletRequest, httpServletResponse, redirectToOnException); return; } throw ex; } } finally { sessionFactory.closeSession(); } } private static void ensureMetamodelIsValid() { final MetaModelInvalidException ex = IsisContext.getMetaModelInvalidExceptionIfAny(); if(ex != null) { final Set<String> validationErrors = ex.getValidationErrors(); final StringBuilder buf = new StringBuilder(); for (String validationError : validationErrors) { buf.append(validationError).append("\n"); } throw new IllegalStateException("Metamodel validation errors: \n" + buf.toString()); } } protected boolean requestIsPassThru(final HttpServletRequest httpServletRequest) { return passThru != null && httpServletRequest.getRequestURI().startsWith(passThru); } private boolean requestIsIgnoreExtension(final IsisSessionFilter filter, final HttpServletRequest httpRequest) { final String servletPath = httpRequest.getServletPath(); for (final Pattern extension : filter.ignoreExtensions) { if (extension.matcher(servletPath).matches()) { return true; } } return false; } // REVIEW: it ought to be possible to remove this static lookup by binding the IsisSessionFactory to the request in an earlier filter private IsisSessionFactory isisSessionFactoryFrom(final HttpServletRequest httpServletRequest) { return IsisContext.getSessionFactory(); } }