mirror of https://github.com/jacekkow/keycloak-protocol-cas

Matthias Piepkorn
2017-01-29 57a6c100075987e88523d0334ccc444e0b652e55
Add support for single logout
2 files added
4 files modified
136 ■■■■■ changed files
pom.xml 6 ●●●●● patch | view | raw | blame | history
src/main/java/org/keycloak/protocol/cas/CASLoginProtocol.java 23 ●●●●● patch | view | raw | blame | history
src/main/java/org/keycloak/protocol/cas/endpoints/LogoutEndpoint.java 2 ●●● patch | view | raw | blame | history
src/main/java/org/keycloak/protocol/cas/endpoints/ValidateEndpoint.java 3 ●●●● patch | view | raw | blame | history
src/main/java/org/keycloak/protocol/cas/utils/LogoutHelper.java 70 ●●●●● patch | view | raw | blame | history
src/test/java/org/keycloak/protocol/cas/LogoutHelperTest.java 32 ●●●●● patch | view | raw | blame | history
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>
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
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;
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)) {
src/main/java/org/keycloak/protocol/cas/utils/LogoutHelper.java
New file
@@ -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;
        }
    }
}
src/test/java/org/keycloak/protocol/cas/LogoutHelperTest.java
New file
@@ -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());
    }
}