KEYCLOAK-7094 Support redirect to external logout page

This commit is contained in:
Hynek Mlnarik 2018-05-30 20:44:15 +02:00 committed by Hynek Mlnařík
parent c586c63533
commit 5a241392cf
8 changed files with 163 additions and 26 deletions

View file

@ -66,6 +66,7 @@ import java.io.IOException;
import java.io.InputStream;
import java.util.HashSet;
import java.util.Set;
import java.util.regex.Pattern;
/**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
@ -119,16 +120,27 @@ public abstract class AbstractSamlAuthenticator extends LoginAuthenticator {
tokenStore.logoutAccount();
}
private static final Pattern PROTOCOL_PATTERN = Pattern.compile("^[a-zA-Z][a-zA-Z0-9+.-]*:");
protected void forwardToLogoutPage(Request request, HttpServletResponse response, SamlDeployment deployment) {
RequestDispatcher disp = request.getRequestDispatcher(deployment.getLogoutPage());
final String location = deployment.getLogoutPage();
try {
//make sure the login page is never cached
response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
response.setHeader("Pragma", "no-cache");
response.setHeader("Expires", "0");
if (location == null) {
log.warn("Logout page not set.");
response.sendError(HttpServletResponse.SC_NOT_FOUND);
} else if (PROTOCOL_PATTERN.matcher(location).find()) {
response.sendRedirect(response.encodeRedirectURL(location));
} else {
RequestDispatcher disp = request.getRequestDispatcher(location);
try {
disp.forward(request, response);
}
} catch (ServletException e) {
throw new RuntimeException(e);
} catch (IOException e) {

View file

@ -44,7 +44,7 @@ import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.*;
import java.util.Map;
import java.util.regex.Pattern;
/**
* Keycloak authentication valve
@ -189,16 +189,27 @@ public abstract class AbstractSamlAuthenticatorValve extends FormAuthenticator i
protected abstract GenericPrincipalFactory createPrincipalFactory();
protected abstract boolean forwardToErrorPageInternal(Request request, HttpServletResponse response, Object loginConfig) throws IOException;
protected void forwardToLogoutPage(Request request, HttpServletResponse response,SamlDeployment deployment) {
RequestDispatcher disp = request.getRequestDispatcher(deployment.getLogoutPage());
private static final Pattern PROTOCOL_PATTERN = Pattern.compile("^[a-zA-Z][a-zA-Z0-9+.-]*:");
protected void forwardToLogoutPage(Request request, HttpServletResponse response, SamlDeployment deployment) {
final String location = deployment.getLogoutPage();
try {
//make sure the login page is never cached
response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
response.setHeader("Pragma", "no-cache");
response.setHeader("Expires", "0");
if (location == null) {
log.warn("Logout page not set.");
response.sendError(HttpServletResponse.SC_NOT_FOUND);
} else if (PROTOCOL_PATTERN.matcher(location).find()) {
response.sendRedirect(response.encodeRedirectURL(location));
} else {
RequestDispatcher disp = request.getRequestDispatcher(location);
try {
disp.forward(request.getRequest(), response);
}
} catch (ServletException e) {
throw new RuntimeException(e);
} catch (IOException e) {

View file

@ -33,6 +33,9 @@ import org.keycloak.adapters.spi.AuthOutcome;
import org.keycloak.adapters.spi.HttpFacade;
import org.keycloak.adapters.undertow.UndertowHttpFacade;
import org.keycloak.adapters.undertow.UndertowUserSessionManagement;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Pattern;
/**
* Abstract base class for a Keycloak-enabled Undertow AuthenticationMechanism.
@ -40,6 +43,9 @@ import org.keycloak.adapters.undertow.UndertowUserSessionManagement;
* @author Stan Silvert ssilvert@redhat.com (C) 2014 Red Hat Inc.
*/
public abstract class AbstractSamlAuthMech implements AuthenticationMechanism {
private static final Logger LOG = Logger.getLogger(AbstractSamlAuthMech.class.getName());
public static final AttachmentKey<AuthChallenge> KEYCLOAK_CHALLENGE_ATTACHMENT_KEY = AttachmentKey.create(AuthChallenge.class);
protected SamlDeploymentContext deploymentContext;
protected UndertowUserSessionManagement sessionManagement;
@ -68,12 +74,23 @@ public abstract class AbstractSamlAuthMech implements AuthenticationMechanism {
return StatusCodes.TEMPORARY_REDIRECT;
}
private static final Pattern PROTOCOL_PATTERN = Pattern.compile("^[a-zA-Z][a-zA-Z0-9+.-]*:");
static void sendRedirect(final HttpServerExchange exchange, final String location) {
// TODO - String concatenation to construct URLS is extremely error prone - switch to a URI which will better
// handle this.
if (location == null) {
LOG.log(Level.WARNING, "Logout page not set.");
exchange.setStatusCode(StatusCodes.NOT_FOUND);
exchange.endExchange();
return;
}
if (PROTOCOL_PATTERN.matcher(location).find()) {
exchange.getResponseHeaders().put(Headers.LOCATION, location);
} else {
String loc = exchange.getRequestScheme() + "://" + exchange.getHostAndPort() + location;
exchange.getResponseHeaders().put(Headers.LOCATION, loc);
}
}
protected void registerNotifications(final SecurityContext securityContext) {
@ -142,7 +159,7 @@ public abstract class AbstractSamlAuthMech implements AuthenticationMechanism {
protected void redirectLogout(SamlDeployment deployment, HttpServerExchange exchange) {
String page = deployment.getLogoutPage();
sendRedirect(exchange, page);
exchange.setResponseCode(302);
exchange.setStatusCode(StatusCodes.FOUND);
exchange.endExchange();
}

View file

@ -113,7 +113,11 @@ public class ServletSamlAuthMech extends AbstractSamlAuthMech {
@Override
protected void redirectLogout(SamlDeployment deployment, HttpServerExchange exchange) {
servePage(exchange, deployment.getLogoutPage());
exchange.getResponseHeaders().add(Headers.CACHE_CONTROL, "no-cache, no-store, must-revalidate");
exchange.getResponseHeaders().add(Headers.PRAGMA, "no-cache");
exchange.getResponseHeaders().add(Headers.EXPIRES, "0");
super.redirectLogout(deployment, exchange);
}
@Override

View file

@ -0,0 +1,62 @@
/*
* Copyright 2016 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* 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.keycloak.testsuite.adapter.page;
import org.keycloak.testsuite.page.AbstractPageWithInjectedUrl;
import org.jboss.arquillian.container.test.api.OperateOnDeployment;
import org.jboss.arquillian.test.api.ArquillianResource;
import java.net.URL;
import org.jboss.shrinkwrap.api.ShrinkWrap;
import org.jboss.shrinkwrap.api.asset.StringAsset;
import org.jboss.shrinkwrap.api.spec.WebArchive;
/**
* @author mhajas
*/
public class AdapterLogoutPage extends AbstractPageWithInjectedUrl {
public static final String DEPLOYMENT_NAME = "logout";
private static final String WEB_XML =
"<web-app xmlns=\"http://java.sun.com/xml/ns/javaee\" version=\"3.0\">"
+ " <module-name>" + DEPLOYMENT_NAME + "</module-name>"
+ "</web-app>";
private static final String LOGOUT_PAGE_HTML = "<html><body>Logged out</body></html>";
public static final WebArchive createDeployment() {
return ShrinkWrap.create(WebArchive.class, AdapterLogoutPage.DEPLOYMENT_NAME + ".war")
.addAsWebInfResource(new StringAsset(WEB_XML), "web.xml")
.add(new StringAsset(LOGOUT_PAGE_HTML), "/index.html");
}
@ArquillianResource
@OperateOnDeployment(DEPLOYMENT_NAME)
private URL url;
@Override
public URL getInjectedUrl() {
return url;
}
@Override
public boolean isCurrent() {
return driver.getCurrentUrl().startsWith(url.toString());
}
}

View file

@ -170,11 +170,14 @@ public class DeploymentArchiveProcessor implements ApplicationArchiveProcessor {
modifyDocElementAttribute(doc, "SingleLogoutService", "postBindingUrl", "http", "https");
modifyDocElementAttribute(doc, "SingleLogoutService", "redirectBindingUrl", "8080", System.getProperty("auth.server.https.port"));
modifyDocElementAttribute(doc, "SingleLogoutService", "redirectBindingUrl", "http", "https");
modifyDocElementAttribute(doc, "SP", "logoutPage", "8081", System.getProperty("app.server.https.port"));
modifyDocElementAttribute(doc, "SP", "logoutPage", "http", "https");
} else {
modifyDocElementAttribute(doc, "SingleSignOnService", "bindingUrl", "8080", System.getProperty("auth.server.http.port"));
modifyDocElementAttribute(doc, "SingleSignOnService", "assertionConsumerServiceUrl", "8081", System.getProperty("app.server.http.port"));
modifyDocElementAttribute(doc, "SingleLogoutService", "postBindingUrl", "8080", System.getProperty("auth.server.http.port"));
modifyDocElementAttribute(doc, "SingleLogoutService", "redirectBindingUrl", "8080", System.getProperty("auth.server.http.port"));
modifyDocElementAttribute(doc, "SP", "logoutPage", "8081", System.getProperty("app.server.http.port"));
}
archive.add(new StringAsset(IOUtil.documentToString(doc)), adapterConfigPath);
@ -244,8 +247,13 @@ public class DeploymentArchiveProcessor implements ApplicationArchiveProcessor {
}
protected void modifyWebXml(Archive<?> archive, TestClass testClass) {
Document webXmlDoc = loadXML(
Document webXmlDoc;
try {
webXmlDoc = loadXML(
archive.get(WEBXML_PATH).getAsset().openStream());
} catch (Exception ex) {
throw new RuntimeException("Error when processing " + archive.getName(), ex);
}
if (isTomcatAppServer(testClass.getJavaClass())) {
modifyDocElementValue(webXmlDoc, "auth-method", "KEYCLOAK", "BASIC");
}

View file

@ -123,6 +123,8 @@ import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathExpression;
import javax.xml.xpath.XPathFactory;
import org.jboss.shrinkwrap.api.ShrinkWrap;
import org.jboss.shrinkwrap.api.asset.StringAsset;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
@ -234,6 +236,9 @@ public abstract class AbstractSAMLServletsAdapterTest extends AbstractServletsAd
@Page
protected SalesPostAutodetectServlet salesPostAutodetectServletPage;
@Page
protected AdapterLogoutPage adapterLogoutPage;
@Page
protected EcpSP ecpSPPage;
@ -362,7 +367,13 @@ public abstract class AbstractSAMLServletsAdapterTest extends AbstractServletsAd
@Deployment(name = EmployeeServlet.DEPLOYMENT_NAME)
protected static WebArchive employeeServlet() {
return samlServletDeployment(EmployeeServlet.DEPLOYMENT_NAME, "employee/WEB-INF/web.xml", SamlSPFacade.class, ServletTestUtils.class);
return samlServletDeployment(EmployeeServlet.DEPLOYMENT_NAME, "employee/WEB-INF/web.xml", SamlSPFacade.class, ServletTestUtils.class)
.add(new StringAsset("<html><body>Logged out</body></html>"), "/logout.jsp");
}
@Deployment(name = AdapterLogoutPage.DEPLOYMENT_NAME)
protected static WebArchive logoutWar() {
return AdapterLogoutPage.createDeployment();
}
@Deployment(name = SalesPostAutodetectServlet.DEPLOYMENT_NAME)
@ -641,6 +652,18 @@ public abstract class AbstractSAMLServletsAdapterTest extends AbstractServletsAd
testSuccessfulAndUnauthorizedLogin(employeeSigFrontServletPage, testRealmSAMLRedirectLoginPage);
}
@Test
public void testLogoutRedirectToExternalPage() throws Exception {
employeeServletPage.navigateTo();
assertCurrentUrlStartsWith(testRealmSAMLPostLoginPage);
testRealmSAMLPostLoginPage.form().login("bburke", "password");
assertCurrentUrlStartsWith(employeeServletPage);
WaitUtils.waitForPageToLoad();
employeeServletPage.logout();
adapterLogoutPage.assertCurrent();
}
@Test
public void salesMetadataTest() throws Exception {
Document doc = loadXML(AbstractSAMLServletsAdapterTest.class.getResourceAsStream("/adapter-test/keycloak-saml/sp-metadata.xml"));
@ -980,7 +1003,7 @@ public abstract class AbstractSAMLServletsAdapterTest extends AbstractServletsAd
samlidpInitiatedLoginPage.form().login(bburkeUser);
assertCurrentUrlStartsWith(salesPost2ServletPage);
assertTrue(driver.getCurrentUrl().endsWith("/foo"));
assertThat(driver.getCurrentUrl(), endsWith("/foo"));
waitUntilElement(By.xpath("//body")).text().contains("principal=bburke");
salesPost2ServletPage.logout();
checkLoggedOut(salesPost2ServletPage, testRealmSAMLPostLoginPage);

View file

@ -21,7 +21,7 @@
<SP entityID="http://localhost:8081/employee/"
sslPolicy="EXTERNAL"
nameIDPolicyFormat="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"
logoutPage="/logout.jsp"
logoutPage="http://localhost:8081/logout/index.html"
forceAuthentication="false">
<PrincipalNameMapping policy="FROM_NAME_ID"/>
<RoleIdentifiers>