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