Commit 57a6c100 authored by Matthias Piepkorn's avatar Matthias Piepkorn
Browse files

Add support for single logout

parent 7124d21d
......@@ -81,6 +81,12 @@
<version>${keycloak.version}</version>
<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>
......
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 class CASLoginProtocol implements LoginProtocol {
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 @@ public class CASLoginProtocol implements LoginProtocol {
@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
......
......@@ -18,7 +18,7 @@ import javax.ws.rs.core.Response;
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;
......
......@@ -20,7 +20,7 @@ import javax.ws.rs.GET;
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 @@ public class ValidateEndpoint {
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)) {
......
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;
}
}
}
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());
}
}
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment