pom.xml | ●●●●● patch | view | raw | blame | history | |
src/main/java/org/keycloak/protocol/cas/CASLoginProtocol.java | ●●●●● patch | view | raw | blame | history | |
src/main/java/org/keycloak/protocol/cas/endpoints/LogoutEndpoint.java | ●●●●● patch | view | raw | blame | history | |
src/main/java/org/keycloak/protocol/cas/endpoints/ValidateEndpoint.java | ●●●●● patch | view | raw | blame | history | |
src/main/java/org/keycloak/protocol/cas/utils/LogoutHelper.java | ●●●●● patch | view | raw | blame | history | |
src/test/java/org/keycloak/protocol/cas/LogoutHelperTest.java | ●●●●● 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()); } }