diff --git a/pom.xml b/pom.xml index fe8be8b..1ab5451 100644 --- a/pom.xml +++ b/pom.xml @@ -14,16 +14,16 @@ magnolia-module-cas CAS login handler and filter implementation for Magnolia. - 4.5.3 - 1.6 - 26 + 5.4.2 + 1.7 + 26 scm:git:http://git.magnolia-cms.com/enterprise/cas scm:git:https://git.magnolia-cms.com/enterprise/cas http://git.magnolia-cms.com/gitweb/?p=enterprise/cas.git - HEAD - + HEAD + Jira http://jira.magnolia-cms.com/browse/MGNLCAS @@ -62,7 +62,7 @@ info.magnolia magnolia-ldap - 1.5.3 + 1.6.3 javax.servlet diff --git a/src/main/java/info/magnolia/cms/security/CASLogout.java b/src/main/java/info/magnolia/cms/security/CASLogout.java new file mode 100644 index 0000000..141bbc1 --- /dev/null +++ b/src/main/java/info/magnolia/cms/security/CASLogout.java @@ -0,0 +1,38 @@ +/** + * This file Copyright (c) 2007-2015 Magnolia International + * Ltd. (http://www.magnolia-cms.com). All rights reserved. + * + * + * This program and the accompanying materials are made + * available under the terms of the Magnolia Network Agreement + * which accompanies this distribution, and is available at + * http://www.magnolia-cms.com/mna.html + * + * Any modifications to this file must keep this entire header + * intact. + * + */ +package info.magnolia.cms.security; + +import info.magnolia.cms.security.cas.CASModule; +import info.magnolia.module.ModuleRegistry; +import info.magnolia.objectfactory.Components; + +import java.text.MessageFormat; +import javax.servlet.http.HttpServletRequest; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Subclass of LogoutFilter that overrides the redirect link. + */ +public class CASLogout extends LogoutFilter { + private static final Logger log = LoggerFactory.getLogger(CASLogout.class); + + @Override + protected String resolveLogoutRedirectLink(HttpServletRequest request) { + CASModule moduleConfig = (CASModule) Components.getSingleton(ModuleRegistry.class).getModuleInstance("cas"); + return MessageFormat.format(moduleConfig.getCasLogoutURL(), moduleConfig.getCasServiceURL()); + } +} diff --git a/src/main/java/info/magnolia/cms/security/auth/callback/CASClientCallback.java b/src/main/java/info/magnolia/cms/security/auth/callback/CASClientCallback.java index 850c444..bbdc602 100644 --- a/src/main/java/info/magnolia/cms/security/auth/callback/CASClientCallback.java +++ b/src/main/java/info/magnolia/cms/security/auth/callback/CASClientCallback.java @@ -14,8 +14,8 @@ */ package info.magnolia.cms.security.auth.callback; -import info.magnolia.cms.security.auth.login.CASLogin; import info.magnolia.cms.security.cas.CASModule; +import info.magnolia.context.MgnlContext; import info.magnolia.module.ModuleRegistry; import java.io.IOException; @@ -24,7 +24,6 @@ import javax.inject.Inject; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; -import org.apache.commons.lang.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -44,28 +43,15 @@ public class CASClientCallback extends AbstractHttpClientCallback { } @Override - public boolean accepts(HttpServletRequest request) { - // In case when info.magnolia.cms.security.auth.login.CASLogin handler was used then don't use - // this client callback to avoid infinite redirect loop and fallback to another callbacks in chain - return request.getAttribute(CASLogin.CAS_LOGIN_HANDLER_USED) == null && super.accepts(request); - } - - @Override public void handle(HttpServletRequest request, HttpServletResponse response) { - log.debug("Redirect to CAS server for login/logout."); - - try { - String logout = request.getParameter(LOGOUT_PARAM); - - if (StringUtils.isNotBlank(logout) && StringUtils.equalsIgnoreCase("true", logout)) { - // logout from CAS server - this.redirectToCASURL(response, createLogoutURL()); - } else { - // redirect to CAS server login form + // if this is a logout request, we should do nothing + String logout = request.getParameter(LOGOUT_PARAM); + if (logout != null && logout.equals("true")) return; + if (MgnlContext.getUser() == null || + MgnlContext.getUser().getName().equals("anonymous")) { this.redirectToCASURL(response, createLoginURL()); - } - } catch (Throwable t) { - log.error("CAS ClientCallback", t); + } else { + moduleConfig.printUnauthorizedError(request, response); } } @@ -75,7 +61,7 @@ public class CASClientCallback extends AbstractHttpClientCallback { * @param response HttpServletResponse * @param redirectURL URL the user is redirected to (login/logout) */ - void redirectToCASURL(HttpServletResponse response, String redirectURL) { + protected void redirectToCASURL(HttpServletResponse response, String redirectURL) { try { response.sendRedirect(redirectURL); } catch (IOException ioe) { @@ -88,24 +74,11 @@ public class CASClientCallback extends AbstractHttpClientCallback { * * @return CAS server login URL with service parameter */ - private String createLoginURL() { + protected String createLoginURL() { final StringBuilder casLoginString = new StringBuilder(); casLoginString.append(moduleConfig.getCasLoginURL()); casLoginString.append("?service=").append(moduleConfig.getCasServiceURL()); return casLoginString.toString(); } - - /** - * Create the URL for a CAS server logout. - * - * @return CAS server logout URL - */ - private String createLogoutURL() { - final StringBuilder casLogoutString = new StringBuilder(); - casLogoutString.append(moduleConfig.getCasLogoutURL()); - - return casLogoutString.toString(); - } - } diff --git a/src/main/java/info/magnolia/cms/security/auth/login/CASLogin.java b/src/main/java/info/magnolia/cms/security/auth/login/CASLogin.java index 5b473a6..7084085 100644 --- a/src/main/java/info/magnolia/cms/security/auth/login/CASLogin.java +++ b/src/main/java/info/magnolia/cms/security/auth/login/CASLogin.java @@ -23,7 +23,7 @@ import javax.security.auth.login.LoginException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; -import org.apache.commons.lang.StringUtils; +import org.apache.commons.lang3.StringUtils; import org.jasig.cas.client.validation.Assertion; import org.jasig.cas.client.validation.Cas20ServiceTicketValidator; import org.slf4j.Logger; @@ -77,13 +77,17 @@ public class CASLogin extends LoginHandlerBase implements LoginHandler { return new LoginResult(LoginResult.STATUS_FAILED, new LoginException(errorMsg)); } - // If authentication against CAS server is successful, then set custom request attribute. This attribute - // indicates that info.magnolia.cms.security.auth.callback.CASClientCallback should not be used - // to avoid infinite redirect loop in case when user has no permission or wasn't authenticated in magnolia - request.setAttribute(CAS_LOGIN_HANDLER_USED, true); - - CredentialsCallbackHandler callbackHandler = new PlainTextCallbackHandler(assertion.getPrincipal().getName(), "".toCharArray()); - return authenticate(callbackHandler, getJaasChain()); + CredentialsCallbackHandler callbackHandler = new PlainTextCallbackHandler(assertion.getPrincipal().getName().toLowerCase(), "".toCharArray()); + LoginResult result = authenticate(callbackHandler, getJaasChain()); + if (result.getSubject() == null) { + // CAS returned a valid ticket but the user does not exist in magnolia, so + // we need to show an appropriate error instead of infinitely redirecting + // back to CAS + moduleConfig.printUnrecognizedError(request, response); + // return STATUS_IN_PROCESS so that LoginFilter will halt the filter chain + return new LoginResult(LoginResult.STATUS_IN_PROCESS); + } + return result; } return LoginResult.NOT_HANDLED; } diff --git a/src/main/java/info/magnolia/cms/security/cas/CASModule.java b/src/main/java/info/magnolia/cms/security/cas/CASModule.java index 5b450d9..eb3235d 100644 --- a/src/main/java/info/magnolia/cms/security/cas/CASModule.java +++ b/src/main/java/info/magnolia/cms/security/cas/CASModule.java @@ -15,6 +15,7 @@ package info.magnolia.cms.security.cas; import com.google.inject.Inject; +import info.magnolia.context.MgnlContext; import info.magnolia.init.MagnoliaConfigurationProperties; import info.magnolia.license.EnterpriseLicensedModule; import info.magnolia.license.License; @@ -22,7 +23,7 @@ import info.magnolia.license.LicenseConsts; import info.magnolia.license.LicenseStatus; import info.magnolia.module.ModuleLifecycle; import info.magnolia.module.ModuleLifecycleContext; -import org.apache.commons.lang.StringUtils; +import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -30,6 +31,8 @@ import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.SSLContext; import javax.net.ssl.TrustManager; import javax.net.ssl.X509TrustManager; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; /** * This class handles the basic installation and configuration of the CAS enterprise module. @@ -43,6 +46,8 @@ public class CASModule implements ModuleLifecycle, EnterpriseLicensedModule { private String casServiceURL; private String casTicketRequestParameter; private String casLogoutURL; + private String casUnauthorizedMessage; + private String casUnrecognizedUserMessage; private boolean sslDebugMode = false; private final MagnoliaConfigurationProperties configurationProperties; @@ -102,7 +107,21 @@ public class CASModule implements ModuleLifecycle, EnterpriseLicensedModule { } public String getCasServiceURL() { - return casServiceURL; + // if configured value exists and is an absolute url, just use it. + if (casServiceURL != null && casServiceURL.matches("^\\w{3,15}://.*")) { + return casServiceURL; + } + + // otherwise append the (possibly blank) relative url to the contextPath + final StringBuilder url = new StringBuilder(getRequestURLWithoutPath()); + + if (StringUtils.isNotBlank(casServiceURL)) { + url.append("/").append(StringUtils.strip(casServiceURL, "/")); + } + + url.append("/"); + + return url.toString(); } public void setCasServiceURL(String casServiceURL) { @@ -125,6 +144,26 @@ public class CASModule implements ModuleLifecycle, EnterpriseLicensedModule { this.casLogoutURL = casLogoutURL; } + public String getCasUnauthorizedMessage() { + if (StringUtils.isBlank(casUnauthorizedMessage)) + return "You successfully authenticated but you do not have access to the requested URL."; + return casUnauthorizedMessage; + } + + public void setCasUnauthorizedMessage(String msg) { + this.casUnauthorizedMessage = msg; + } + + public String getCasUnrecognizedUserMessage() { + if (StringUtils.isBlank(casUnrecognizedUserMessage)) + return "You successfully authenticated but you do not have access to this system."; + return casUnrecognizedUserMessage; + } + + public void setCasUnrecognizedUserMessage(String msg) { + this.casUnrecognizedUserMessage = msg; + } + public boolean isSslDebugMode() { return sslDebugMode; } @@ -164,4 +203,39 @@ public class CASModule implements ModuleLifecycle, EnterpriseLicensedModule { } } } + + private String getRequestURLWithoutPath() { + final String url = MgnlContext.getAggregationState().getOriginalURL(); + + final HttpServletRequest request = MgnlContext.getWebContext().getRequest(); + final StringBuilder serviceURLString = new StringBuilder(); + serviceURLString.append(request.getScheme()).append("://").append(request.getServerName()); + if ((request.getScheme().equals("http") && request.getServerPort() != 80) || + (request.getScheme().equals("https") && request.getServerPort() != 443) || + !request.getScheme().contains("http")) + serviceURLString.append(":").append(request.getServerPort()); + + serviceURLString.append(request.getContextPath()); + + return serviceURLString.toString(); + } + + public void printLoginError(HttpServletRequest request, HttpServletResponse response, String message) { + // log them out so they can refresh after someone fixes their permissions + if (request.getSession(false) != null) { + request.getSession().invalidate(); + } + try { + response.getWriter().write(message); + } catch (Exception e) { + log.error("Unable to write to response body.", e); + } + } + + public void printUnauthorizedError(HttpServletRequest request, HttpServletResponse response) { + printLoginError(request, response, getCasUnauthorizedMessage()); + } + public void printUnrecognizedError(HttpServletRequest request, HttpServletResponse response) { + printLoginError(request, response, getCasUnrecognizedUserMessage()); + } } diff --git a/src/main/java/info/magnolia/cms/security/cas/CASModuleVersionHandler.java b/src/main/java/info/magnolia/cms/security/cas/CASModuleVersionHandler.java new file mode 100644 index 0000000..c3fb682 --- /dev/null +++ b/src/main/java/info/magnolia/cms/security/cas/CASModuleVersionHandler.java @@ -0,0 +1,60 @@ +/** + * This file Copyright (c) 2007-2015 Magnolia International + * Ltd. (http://www.magnolia-cms.com). All rights reserved. + * + * + * This program and the accompanying materials are made + * available under the terms of the Magnolia Network Agreement + * which accompanies this distribution, and is available at + * http://www.magnolia-cms.com/mna.html + * + * Any modifications to this file must keep this entire header + * intact. + * + */ +package info.magnolia.cms.security.cas; + +import info.magnolia.module.DefaultModuleVersionHandler; +import info.magnolia.module.InstallContext; +import info.magnolia.module.delta.BootstrapSingleResource; +import info.magnolia.module.delta.DeltaBuilder; +import info.magnolia.module.delta.FilterOrderingTask; +import info.magnolia.module.delta.SetPropertyTask; +import info.magnolia.module.delta.Task; +import info.magnolia.repository.RepositoryConstants; + +import java.util.ArrayList; +import java.util.List; + +/** + * Default module version handler. + */ +public class CASModuleVersionHandler extends DefaultModuleVersionHandler { + + protected static final String FILTER_LOGIN = "login"; + protected static final String FILTER_LOGOUT = "logout"; + protected static final String FILTER_CAS_SSO = "casSingleSignOut"; + + public CASModuleVersionHandler() { + + register(DeltaBuilder.update("1.1.2", "") + .addTask(new BootstrapSingleResource("Bootstrap", "CAS Single Sign Out Filter", "/mgnl-bootstrap/cas/config.server.filters.casSingleSignOut.xml")) + .addTask(new FilterOrderingTask(FILTER_CAS_SSO, new String[]{FILTER_LOGIN})) + ); + register(DeltaBuilder.update("1.1.4", "") + .addTask(useOurLogoutFilterTask()) + ); + } + + @Override + protected List getExtraInstallTasks(InstallContext installContext) { + ArrayList tasks = new ArrayList(); + tasks.add(new FilterOrderingTask(FILTER_CAS_SSO, new String[]{FILTER_LOGIN})); + tasks.add(useOurLogoutFilterTask()); + return tasks; + } + + protected Task useOurLogoutFilterTask() { + return new SetPropertyTask(RepositoryConstants.CONFIG, "/server/filters/logout", "class", "info.magnolia.cms.security.CASLogout"); + } +} diff --git a/src/main/java/info/magnolia/cms/security/cas/CASSingleSignOutFilter.java b/src/main/java/info/magnolia/cms/security/cas/CASSingleSignOutFilter.java new file mode 100644 index 0000000..1521290 --- /dev/null +++ b/src/main/java/info/magnolia/cms/security/cas/CASSingleSignOutFilter.java @@ -0,0 +1,53 @@ +/** + * This file Copyright (c) 2007-2015 Magnolia International + * Ltd. (http://www.magnolia-cms.com). All rights reserved. + * + * + * This program and the accompanying materials are made + * available under the terms of the Magnolia Network Agreement + * which accompanies this distribution, and is available at + * http://www.magnolia-cms.com/mna.html + * + * Any modifications to this file must keep this entire header + * intact. + * + */ +package info.magnolia.cms.security.cas; + +import info.magnolia.cms.filters.AbstractMgnlFilter; + +import java.io.IOException; + +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.jasig.cas.client.session.SingleSignOutFilter; + +/** + * Wrapper around {@link SingleSignOutFilter} to make it a {@link MgnlFilter}. + */ +public class CASSingleSignOutFilter extends AbstractMgnlFilter { + private SingleSignOutFilter singleSignOutFilter = new SingleSignOutFilter(); + + @Override + public void init(FilterConfig filterConfig) throws ServletException { + singleSignOutFilter.init(filterConfig); + } + + @Override + public void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { + singleSignOutFilter.doFilter(request, response, chain); + } + + public void setArtifactParameterName(final String name) { + singleSignOutFilter.setArtifactParameterName(name); + } + + public void setLogoutParameterName(final String name) { + singleSignOutFilter.setLogoutParameterName(name); + } + +} diff --git a/src/main/resources/META-INF/magnolia/cas.xml b/src/main/resources/META-INF/magnolia/cas.xml index 4e5523b..dd068b3 100644 --- a/src/main/resources/META-INF/magnolia/cas.xml +++ b/src/main/resources/META-INF/magnolia/cas.xml @@ -4,6 +4,7 @@ cas Magnolia CAS Module info.magnolia.cms.security.cas.CASModule + info.magnolia.cms.security.cas.CASModuleVersionHandler ${project.version} diff --git a/src/main/resources/mgnl-bootstrap/cas/config.server.filters.casSingleSignOut.xml b/src/main/resources/mgnl-bootstrap/cas/config.server.filters.casSingleSignOut.xml new file mode 100644 index 0000000..7c04bfd --- /dev/null +++ b/src/main/resources/mgnl-bootstrap/cas/config.server.filters.casSingleSignOut.xml @@ -0,0 +1,27 @@ + + + + mgnl:content + + + 589146c5-5816-40c7-adc6-1e7511617feb + + + info.magnolia.cms.security.cas.CASSingleSignOutFilter + + + admin + + + 2015-08-13T15:52:15.496-05:00 + + + superuser + + + 2015-08-14T12:52:32.925-05:00 + + + superuser + + diff --git a/src/test/java/info/magnolia/cms/security/auth/callback/CASClientCallbackTest.java b/src/test/java/info/magnolia/cms/security/auth/callback/CASClientCallbackTest.java deleted file mode 100644 index b1ace0e..0000000 --- a/src/test/java/info/magnolia/cms/security/auth/callback/CASClientCallbackTest.java +++ /dev/null @@ -1,70 +0,0 @@ -/** - * This file Copyright (c) 2014-2015 Magnolia International - * Ltd. (http://www.magnolia-cms.com). All rights reserved. - * - * - * This program and the accompanying materials are made - * available under the terms of the Magnolia Network Agreement - * which accompanies this distribution, and is available at - * http://www.magnolia-cms.com/mna.html - * - * Any modifications to this file must keep this entire header - * intact. - * - */ -package info.magnolia.cms.security.auth.callback; - -import static org.junit.Assert.*; -import static org.mockito.Mockito.*; - -import info.magnolia.cms.security.auth.login.CASLogin; -import info.magnolia.cms.security.cas.CASModule; -import info.magnolia.init.MagnoliaConfigurationProperties; -import info.magnolia.module.ModuleRegistry; - -import javax.servlet.http.HttpServletRequest; - -import org.junit.Before; -import org.junit.Test; - -/** - * Test class for {@link CASClientCallback}. - */ -public class CASClientCallbackTest { - - private CASClientCallback casClientCallback; - private HttpServletRequest request; - - @Before - public void setUp() { - ModuleRegistry moduleRegistry = mock(ModuleRegistry.class); - - CASModule casModule = new CASModule(mock(MagnoliaConfigurationProperties.class)); - when(moduleRegistry.getModuleInstance("cas")).thenReturn(casModule); - - casClientCallback = new CASClientCallback(moduleRegistry); - - request = mock(HttpServletRequest.class); - } - - @Test - public void testDoNotAcceptCASClientCallbackWhenCASLoginHandlerWasUsed(){ - // GIVEN - when(request.getAttribute(CASLogin.CAS_LOGIN_HANDLER_USED)).thenReturn("true"); - - // WHEN - boolean accepts = casClientCallback.accepts(request); - - // THEN - assertFalse(accepts); - } - - @Test - public void testAcceptCASClientCallbackWhenCASLoginHandlerWasNotUsed(){ - // WHEN - boolean accepts = casClientCallback.accepts(request); - - // THEN - assertTrue(accepts); - } -} diff --git a/src/test/java/info/magnolia/cms/security/auth/login/CASLoginTest.java b/src/test/java/info/magnolia/cms/security/auth/login/CASLoginTest.java index 4da4b2c..3795aed 100644 --- a/src/test/java/info/magnolia/cms/security/auth/login/CASLoginTest.java +++ b/src/test/java/info/magnolia/cms/security/auth/login/CASLoginTest.java @@ -14,11 +14,8 @@ */ package info.magnolia.cms.security.auth.login; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.never; import static org.powermock.api.mockito.PowerMockito.*; -import info.magnolia.cms.security.SecuritySupport; import info.magnolia.cms.security.cas.CASModule; import info.magnolia.logging.AuditLoggingUtil; import info.magnolia.module.ModuleRegistry; @@ -30,21 +27,21 @@ import java.util.Map; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; -import org.jasig.cas.client.authentication.AttributePrincipal; -import org.jasig.cas.client.validation.Assertion; import org.jasig.cas.client.validation.Cas20ServiceTicketValidator; +import org.jasig.cas.client.validation.TicketValidationException; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PowerMockIgnore; import org.powermock.core.classloader.annotations.PrepareForTest; import org.powermock.modules.junit4.PowerMockRunner; - /** * Test class for {@link CASLogin}. */ @RunWith(PowerMockRunner.class) @PrepareForTest(CASLogin.class) +@PowerMockIgnore("javax.security.*") public class CASLoginTest { private final String CAS_SERVIS_TICKET_PARAMETER = "ticket"; @@ -75,38 +72,12 @@ public class CASLoginTest { } @Test - public void testCASLoginHandlerUsedAttributeIsSetWhenSuccessfullyAuthenticatedAgainstCASServer() throws Exception { + public void testLoginResultContainsLoginExceptionWhenFailedToValidateCASTicketFromAnyReason() throws Exception { // GIVEN Cas20ServiceTicketValidator validator = mock(Cas20ServiceTicketValidator.class); whenNew(Cas20ServiceTicketValidator.class).withArguments(null).thenReturn(validator); - Assertion assertion = mock(Assertion.class); - when(validator.validate(CAS_TICKET_NUMBER, null)).thenReturn(assertion); - AttributePrincipal principal = mock(AttributePrincipal.class); - when(assertion.getPrincipal()).thenReturn(principal); - when(principal.getName()).thenReturn("testUser"); - - SecuritySupport securitySupport = mock(SecuritySupport.class); - ComponentsTestUtil.setInstance(SecuritySupport.class, securitySupport); - - // WHEN - casLogin.handle(request, response); + when(validator.validate(CAS_TICKET_NUMBER, null)).thenThrow(new TicketValidationException("failed on purpose")); - // THEN - verify(request).setAttribute(CASLogin.CAS_LOGIN_HANDLER_USED, true); - } - - @Test - public void testCASLoginHandlerUsedAttributeIsNotSetWhenFailedToValidateCASTicketFromAnyReason() throws Exception { - // WHEN - casLogin.handle(request, response); - - // THEN - verify(request, never()).setAttribute(CASLogin.CAS_LOGIN_HANDLER_USED, true); - } - - - @Test - public void testLoginResultContainsLoginExceptionWhenFailedToValidateCASTicketFromAnyReason(){ // WHEN LoginResult loginResult = casLogin.handle(request, response);