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