From 57a6c100075987e88523d0334ccc444e0b652e55 Mon Sep 17 00:00:00 2001 From: Matthias Piepkorn <mpiepk@gmail.com> Date: Sun, 29 Jan 2017 17:57:10 +0000 Subject: [PATCH] Add support for single logout --- src/test/java/org/keycloak/protocol/cas/LogoutHelperTest.java | 32 ++++++++++ src/main/java/org/keycloak/protocol/cas/endpoints/ValidateEndpoint.java | 3 src/main/java/org/keycloak/protocol/cas/utils/LogoutHelper.java | 70 +++++++++++++++++++++++ src/main/java/org/keycloak/protocol/cas/CASLoginProtocol.java | 23 +++++++ pom.xml | 6 ++ src/main/java/org/keycloak/protocol/cas/endpoints/LogoutEndpoint.java | 2 6 files changed, 134 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index ec4165e..2d52b17 100644 --- a/pom.xml +++ b/pom.xml @@ -82,6 +82,12 @@ <scope>provided</scope> </dependency> <dependency> + <groupId>org.keycloak</groupId> + <artifactId>keycloak-saml-core</artifactId> + <version>${keycloak.version}</version> + <scope>provided</scope> + </dependency> + <dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>${junit.version}</version> diff --git a/src/main/java/org/keycloak/protocol/cas/CASLoginProtocol.java b/src/main/java/org/keycloak/protocol/cas/CASLoginProtocol.java index 17e435e..8198a35 100644 --- a/src/main/java/org/keycloak/protocol/cas/CASLoginProtocol.java +++ b/src/main/java/org/keycloak/protocol/cas/CASLoginProtocol.java @@ -1,19 +1,25 @@ package org.keycloak.protocol.cas; +import org.apache.http.HttpEntity; +import org.jboss.logging.Logger; import org.keycloak.common.util.KeycloakUriBuilder; import org.keycloak.events.EventBuilder; import org.keycloak.events.EventType; import org.keycloak.models.*; import org.keycloak.protocol.LoginProtocol; +import org.keycloak.protocol.cas.utils.LogoutHelper; import org.keycloak.services.managers.ClientSessionCode; import org.keycloak.services.managers.ResourceAdminManager; import javax.ws.rs.core.HttpHeaders; import javax.ws.rs.core.Response; import javax.ws.rs.core.UriInfo; +import java.io.IOException; import java.net.URI; public class CASLoginProtocol implements LoginProtocol { + private static final Logger logger = Logger.getLogger(CASLoginProtocol.class); + public static final String LOGIN_PROTOCOL = "cas"; public static final String SERVICE_PARAM = "service"; @@ -25,6 +31,7 @@ public static final String TICKET_RESPONSE_PARAM = "ticket"; public static final String SERVICE_TICKET_PREFIX = "ST-"; + public static final String SESSION_SERVICE_TICKET = "service_ticket"; protected KeycloakSession session; protected RealmModel realm; @@ -96,10 +103,26 @@ @Override public void backchannelLogout(UserSessionModel userSession, ClientSessionModel clientSession) { + String logoutUrl = clientSession.getRedirectUri(); + String serviceTicket = clientSession.getNote(CASLoginProtocol.SESSION_SERVICE_TICKET); + //check if session is fully authenticated (i.e. serviceValidate has been called) + if (serviceTicket != null && !serviceTicket.isEmpty()) { + sendSingleLogoutRequest(logoutUrl, serviceTicket); + } ClientModel client = clientSession.getClient(); new ResourceAdminManager(session).logoutClientSession(uriInfo.getRequestUri(), realm, client, clientSession); } + private void sendSingleLogoutRequest(String logoutUrl, String serviceTicket) { + HttpEntity requestEntity = LogoutHelper.buildSingleLogoutRequest(serviceTicket); + try { + LogoutHelper.postWithRedirect(session, logoutUrl, requestEntity); + logger.debug("Sent CAS single logout for service " + logoutUrl); + } catch (IOException e) { + logger.warn("Failed to call CAS service for logout: " + logoutUrl, e); + } + } + @Override public Response frontchannelLogout(UserSessionModel userSession, ClientSessionModel clientSession) { // todo oidc redirect support diff --git a/src/main/java/org/keycloak/protocol/cas/endpoints/LogoutEndpoint.java b/src/main/java/org/keycloak/protocol/cas/endpoints/LogoutEndpoint.java index c40da69..b5e011c 100644 --- a/src/main/java/org/keycloak/protocol/cas/endpoints/LogoutEndpoint.java +++ b/src/main/java/org/keycloak/protocol/cas/endpoints/LogoutEndpoint.java @@ -18,7 +18,7 @@ import javax.ws.rs.core.UriInfo; public class LogoutEndpoint { - private static final Logger logger = Logger.getLogger(org.keycloak.protocol.oidc.endpoints.LogoutEndpoint.class); + private static final Logger logger = Logger.getLogger(LogoutEndpoint.class); @Context private KeycloakSession session; diff --git a/src/main/java/org/keycloak/protocol/cas/endpoints/ValidateEndpoint.java b/src/main/java/org/keycloak/protocol/cas/endpoints/ValidateEndpoint.java index edfa129..b2b0702 100644 --- a/src/main/java/org/keycloak/protocol/cas/endpoints/ValidateEndpoint.java +++ b/src/main/java/org/keycloak/protocol/cas/endpoints/ValidateEndpoint.java @@ -20,7 +20,7 @@ import javax.ws.rs.core.*; public class ValidateEndpoint { - protected static final Logger logger = Logger.getLogger(org.keycloak.protocol.oidc.endpoints.LogoutEndpoint.class); + protected static final Logger logger = Logger.getLogger(ValidateEndpoint.class); private static final String RESPONSE_OK = "yes\n"; private static final String RESPONSE_FAILED = "no\n"; @@ -152,6 +152,7 @@ throw new CASValidationException(CASErrorCode.INVALID_TICKET, "Code is expired", Response.Status.BAD_REQUEST); } + clientSession.setNote(CASLoginProtocol.SESSION_SERVICE_TICKET, ticket); parseResult.getCode().setAction(null); if (requireReauth && AuthenticationManager.isSSOAuthentication(clientSession)) { diff --git a/src/main/java/org/keycloak/protocol/cas/utils/LogoutHelper.java b/src/main/java/org/keycloak/protocol/cas/utils/LogoutHelper.java new file mode 100644 index 0000000..64c31b9 --- /dev/null +++ b/src/main/java/org/keycloak/protocol/cas/utils/LogoutHelper.java @@ -0,0 +1,70 @@ +package org.keycloak.protocol.cas.utils; + +import org.apache.http.HttpEntity; +import org.apache.http.HttpResponse; +import org.apache.http.client.HttpClient; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.StringEntity; +import org.keycloak.connections.httpclient.HttpClientProvider; +import org.keycloak.models.KeycloakSession; +import org.keycloak.saml.common.exceptions.ConfigurationException; +import org.keycloak.saml.processing.core.saml.v2.common.IDGenerator; +import org.keycloak.saml.processing.core.saml.v2.util.XMLTimeUtil; + +import javax.ws.rs.core.HttpHeaders; +import javax.xml.datatype.XMLGregorianCalendar; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +public class LogoutHelper { + //although it looks alike, the CAS SLO protocol has nothing to do with SAML; so we build the format + //required by the spec manually + private static final String TEMPLATE = "<samlp:LogoutRequest xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\" ID=\"$ID\" Version=\"2.0\" IssueInstant=\"$ISSUE_INSTANT\">\n" + + " <saml:NameID xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\">@NOT_USED@</saml:NameID>\n" + + " <samlp:SessionIndex>$SESSION_IDENTIFIER</samlp:SessionIndex>\n" + + "</samlp:LogoutRequest>"; + + public static HttpEntity buildSingleLogoutRequest(String serviceTicket) { + String id = IDGenerator.create("ID_"); + XMLGregorianCalendar issueInstant; + try { + issueInstant = XMLTimeUtil.getIssueInstant(); + } catch (ConfigurationException e) { + throw new RuntimeException(e); + } + String document = TEMPLATE.replace("$ID", id).replace("$ISSUE_INSTANT", issueInstant.toString()) + .replace("$SESSION_IDENTIFIER", serviceTicket); + return new StringEntity(document, ContentType.APPLICATION_XML.withCharset(StandardCharsets.UTF_8)); + } + + public static void postWithRedirect(KeycloakSession session, String url, HttpEntity postBody) throws IOException { + HttpClient httpClient = session.getProvider(HttpClientProvider.class).getHttpClient(); + for (int i = 0; i < 2; i++) { // follow redirects once + HttpPost post = new HttpPost(url); + post.setEntity(postBody); + HttpResponse response = httpClient.execute(post); + try { + int status = response.getStatusLine().getStatusCode(); + if (status == 302 && !url.endsWith("/")) { + String redirect = response.getFirstHeader(HttpHeaders.LOCATION).getValue(); + String withSlash = url + "/"; + if (withSlash.equals(redirect)) { + url = withSlash; + continue; + } + } + } finally { + HttpEntity entity = response.getEntity(); + if (entity != null) { + InputStream is = entity.getContent(); + if (is != null) + is.close(); + } + + } + break; + } + } +} diff --git a/src/test/java/org/keycloak/protocol/cas/LogoutHelperTest.java b/src/test/java/org/keycloak/protocol/cas/LogoutHelperTest.java new file mode 100644 index 0000000..76169df --- /dev/null +++ b/src/test/java/org/keycloak/protocol/cas/LogoutHelperTest.java @@ -0,0 +1,32 @@ +package org.keycloak.protocol.cas; + +import org.apache.http.HttpEntity; +import org.junit.Test; +import org.keycloak.protocol.cas.utils.LogoutHelper; +import org.keycloak.saml.common.constants.JBossSAMLURIConstants; +import org.keycloak.saml.common.util.DocumentUtil; +import org.w3c.dom.Document; +import org.w3c.dom.Node; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +public class LogoutHelperTest { + @Test + public void testLogoutRequest() throws Exception { + HttpEntity requestEntity = LogoutHelper.buildSingleLogoutRequest("ST-test"); + Document doc = DocumentUtil.getDocument(requestEntity.getContent()); + + assertEquals("LogoutRequest", doc.getDocumentElement().getLocalName()); + assertEquals(JBossSAMLURIConstants.PROTOCOL_NSURI.get(), doc.getDocumentElement().getNamespaceURI()); + assertEquals("2.0", doc.getDocumentElement().getAttribute("Version")); + assertFalse(doc.getDocumentElement().getAttribute("ID").isEmpty()); + assertFalse(doc.getDocumentElement().getAttribute("IssueInstant").isEmpty()); + + Node nameID = doc.getDocumentElement().getElementsByTagNameNS(JBossSAMLURIConstants.ASSERTION_NSURI.get(), "NameID").item(0); + assertFalse(nameID.getTextContent() == null || nameID.getTextContent().isEmpty()); + + Node sessionIndex = doc.getDocumentElement().getElementsByTagNameNS(JBossSAMLURIConstants.PROTOCOL_NSURI.get(), "SessionIndex").item(0); + assertEquals("ST-test", sessionIndex.getTextContent()); + } +} -- Gitblit v1.9.1