From 7c600e2f4beebed52fe6862a9280c714594334e4 Mon Sep 17 00:00:00 2001 From: Bill Burke Date: Thu, 8 Oct 2015 16:19:43 -0400 Subject: [PATCH] SAML SP Filter --- .../reference/en/en-US/master.xml | 4 +- .../reference/en/en-US/modules/overview.xml | 9 + .../adapters/InMemorySessionIdMapper.java | 5 + .../keycloak/adapters/SessionIdMapper.java | 2 + integration/pom.xml | 1 + integration/servlet-adapter-spi/pom.xml | 53 +++++ .../adapters/servlet/FilterSessionStore.java | 98 ++++++++ .../adapters/servlet/ServletHttpFacade.java | 189 +++++++++++++++ pom.xml | 10 + .../adapters/saml/SamlAuthenticator.java | 3 + saml/client-adapter/pom.xml | 1 + saml/client-adapter/servlet-filter/pom.xml | 69 ++++++ .../saml/servlet/FilterSamlSessionStore.java | 218 ++++++++++++++++++ .../adapters/saml/servlet/SamlFilter.java | 147 ++++++++++++ testsuite/integration/pom.xml | 4 + .../keycloaksaml/SendUsernameServlet.java | 6 +- .../testsuite/samlfilter/SamlAdapterTest.java | 147 ++++++++++++ .../samlfilter/SamlKeycloakRule.java | 125 ++++++++++ 18 files changed, 1089 insertions(+), 2 deletions(-) create mode 100755 docbook/saml-adapter-docs/reference/en/en-US/modules/overview.xml create mode 100755 integration/servlet-adapter-spi/pom.xml create mode 100755 integration/servlet-adapter-spi/src/main/java/org/keycloak/adapters/servlet/FilterSessionStore.java create mode 100755 integration/servlet-adapter-spi/src/main/java/org/keycloak/adapters/servlet/ServletHttpFacade.java create mode 100755 saml/client-adapter/servlet-filter/pom.xml create mode 100755 saml/client-adapter/servlet-filter/src/main/java/org/keycloak/adapters/saml/servlet/FilterSamlSessionStore.java create mode 100755 saml/client-adapter/servlet-filter/src/main/java/org/keycloak/adapters/saml/servlet/SamlFilter.java create mode 100755 testsuite/integration/src/test/java/org/keycloak/testsuite/samlfilter/SamlAdapterTest.java create mode 100755 testsuite/integration/src/test/java/org/keycloak/testsuite/samlfilter/SamlKeycloakRule.java diff --git a/docbook/saml-adapter-docs/reference/en/en-US/master.xml b/docbook/saml-adapter-docs/reference/en/en-US/master.xml index 55ce6608ca..5c36b77ff8 100755 --- a/docbook/saml-adapter-docs/reference/en/en-US/master.xml +++ b/docbook/saml-adapter-docs/reference/en/en-US/master.xml @@ -1,6 +1,7 @@ @@ -13,7 +14,7 @@ Keycloak SAML Client Adapter Reference Guide - SAML 2.0 Client Adapters for Java Applications + SAML 2.0 Client Adapters &project.version; @@ -39,6 +40,7 @@ This one is short + &Overview; &AdapterConfig; &JBossAdapter; &TomcatAdapter; diff --git a/docbook/saml-adapter-docs/reference/en/en-US/modules/overview.xml b/docbook/saml-adapter-docs/reference/en/en-US/modules/overview.xml new file mode 100755 index 0000000000..1616bbb6ee --- /dev/null +++ b/docbook/saml-adapter-docs/reference/en/en-US/modules/overview.xml @@ -0,0 +1,9 @@ + + Overview + + This document describes the Keycloak SAML client adapter and how it can be configured for a variety of platforms. + The Keycloak SAML client adapter is a standalone component that provides generic SAML 2.0 support for your web applications. + There are no Keycloak server extensions built into it. As long as the IDP you are talking to supports standard SAML, the + Keycloak SAML client adapter should be able to integrate with it. + + \ No newline at end of file diff --git a/integration/adapter-spi/src/main/java/org/keycloak/adapters/InMemorySessionIdMapper.java b/integration/adapter-spi/src/main/java/org/keycloak/adapters/InMemorySessionIdMapper.java index 4470c150f9..1be7159dcc 100755 --- a/integration/adapter-spi/src/main/java/org/keycloak/adapters/InMemorySessionIdMapper.java +++ b/integration/adapter-spi/src/main/java/org/keycloak/adapters/InMemorySessionIdMapper.java @@ -17,6 +17,11 @@ public class InMemorySessionIdMapper implements SessionIdMapper { ConcurrentHashMap> principalToSession = new ConcurrentHashMap<>(); ConcurrentHashMap sessionToPrincipal = new ConcurrentHashMap<>(); + @Override + public boolean hasSession(String id) { + return sessionToSso.containsKey(id) || sessionToPrincipal.containsKey(id); + } + @Override public Set getUserSessions(String principal) { Set lookup = principalToSession.get(principal); diff --git a/integration/adapter-spi/src/main/java/org/keycloak/adapters/SessionIdMapper.java b/integration/adapter-spi/src/main/java/org/keycloak/adapters/SessionIdMapper.java index 646f71b429..d52e0299ac 100755 --- a/integration/adapter-spi/src/main/java/org/keycloak/adapters/SessionIdMapper.java +++ b/integration/adapter-spi/src/main/java/org/keycloak/adapters/SessionIdMapper.java @@ -7,6 +7,8 @@ import java.util.Set; * @version $Revision: 1 $ */ public interface SessionIdMapper { + boolean hasSession(String id); + Set getUserSessions(String principal); String getSessionFromSSO(String sso); diff --git a/integration/pom.xml b/integration/pom.xml index e4b7f27d0d..c1d3346e7d 100755 --- a/integration/pom.xml +++ b/integration/pom.xml @@ -15,6 +15,7 @@ adapter-spi + servlet-adapter-spi adapter-core jaxrs-oauth-client servlet-oauth-client diff --git a/integration/servlet-adapter-spi/pom.xml b/integration/servlet-adapter-spi/pom.xml new file mode 100755 index 0000000000..b93ed634f5 --- /dev/null +++ b/integration/servlet-adapter-spi/pom.xml @@ -0,0 +1,53 @@ + + + + keycloak-parent + org.keycloak + 1.6.0.Final-SNAPSHOT + ../../pom.xml + + 4.0.0 + + keycloak-servlet-adapter-spi + Keycloak Servlet Integration + + + + + org.jboss.logging + jboss-logging + + + org.keycloak + keycloak-adapter-spi + + + org.keycloak + keycloak-common + + + org.jboss.spec.javax.servlet + jboss-servlet-api_3.0_spec + provided + + + junit + junit + test + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${maven.compiler.source} + ${maven.compiler.target} + + + + + + diff --git a/integration/servlet-adapter-spi/src/main/java/org/keycloak/adapters/servlet/FilterSessionStore.java b/integration/servlet-adapter-spi/src/main/java/org/keycloak/adapters/servlet/FilterSessionStore.java new file mode 100755 index 0000000000..51c6909a40 --- /dev/null +++ b/integration/servlet-adapter-spi/src/main/java/org/keycloak/adapters/servlet/FilterSessionStore.java @@ -0,0 +1,98 @@ +package org.keycloak.adapters.servlet; + +import org.keycloak.adapters.AdapterSessionStore; +import org.keycloak.adapters.HttpFacade; +import org.keycloak.util.MultivaluedHashMap; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpSession; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Enumeration; + +/** + * @author Bill Burke + * @version $Revision: 1 $ + */ +public class FilterSessionStore implements AdapterSessionStore { + public static final String REDIRECT_URI = "__REDIRECT_URI"; + public static final String SAVED_METHOD = "__SAVED_METHOD"; + public static final String SAVED_HEADERS = "__SAVED_HEADERS"; + public static final String SAVED_BODY = "__SAVED_BODY"; + protected final HttpServletRequest request; + protected final HttpFacade facade; + protected final int maxBuffer; + protected byte[] restoredBuffer = null; + + public FilterSessionStore(HttpServletRequest request, HttpFacade facade, int maxBuffer) { + this.request = request; + this.facade = facade; + this.maxBuffer = maxBuffer; + } + + public void clearSavedRequest(HttpSession session) { + session.removeAttribute(REDIRECT_URI); + session.removeAttribute(SAVED_METHOD); + session.removeAttribute(SAVED_HEADERS); + session.removeAttribute(SAVED_BODY); + } + + public String getRedirectUri() { + HttpSession session = request.getSession(true); + return (String)session.getAttribute(REDIRECT_URI); + } + + @Override + public boolean restoreRequest() { + HttpSession session = request.getSession(false); + if (session == null) return false; + return session.getAttribute(REDIRECT_URI) != null; + } + + + @Override + public void saveRequest() { + HttpSession session = request.getSession(true); + session.setAttribute(REDIRECT_URI, facade.getRequest().getURI()); + session.setAttribute(SAVED_METHOD, request.getMethod()); + MultivaluedHashMap headers = new MultivaluedHashMap<>(); + Enumeration names = request.getHeaderNames(); + while (names.hasMoreElements()) { + String name = names.nextElement(); + Enumeration values = request.getHeaders(name); + while (values.hasMoreElements()) { + headers.add(name, values.nextElement()); + } + } + session.setAttribute(SAVED_HEADERS, headers); + if (request.getMethod().equalsIgnoreCase("GET")) { + return; + } + ByteArrayOutputStream os = new ByteArrayOutputStream(); + + byte[] buffer = new byte[4096]; + int bytesRead; + int totalRead = 0; + try { + InputStream is = request.getInputStream(); + + while ( (bytesRead = is.read(buffer) ) >= 0) { + os.write(buffer); + totalRead += bytesRead; + if (totalRead > maxBuffer) { + throw new RuntimeException("max buffer reached on a saved request"); + } + + } + } catch (IOException e) { + throw new RuntimeException(e); + } + byte[] body = os.toByteArray(); + // Only save the request body if there is something to save + if (body.length > 0) { + session.setAttribute(SAVED_BODY, body); + } + } + +} diff --git a/integration/servlet-adapter-spi/src/main/java/org/keycloak/adapters/servlet/ServletHttpFacade.java b/integration/servlet-adapter-spi/src/main/java/org/keycloak/adapters/servlet/ServletHttpFacade.java new file mode 100755 index 0000000000..bc4eaa3e4a --- /dev/null +++ b/integration/servlet-adapter-spi/src/main/java/org/keycloak/adapters/servlet/ServletHttpFacade.java @@ -0,0 +1,189 @@ +package org.keycloak.adapters.servlet; + +import org.bouncycastle.ocsp.Req; +import org.keycloak.adapters.HttpFacade; +import org.keycloak.util.KeycloakUriBuilder; +import org.keycloak.util.MultivaluedHashMap; +import org.keycloak.util.ServerCookie; +import org.keycloak.util.UriUtils; + +import javax.security.cert.X509Certificate; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URI; +import java.util.Enumeration; +import java.util.LinkedList; +import java.util.List; + +/** + * @author Bill Burke + * @version $Revision: 1 $ + */ +public class ServletHttpFacade implements HttpFacade { + protected final RequestFacade requestFacade = new RequestFacade(); + protected final ResponseFacade responseFacade = new ResponseFacade(); + protected HttpServletRequest request; + protected HttpServletResponse response; + protected MultivaluedHashMap queryParameters; + + public ServletHttpFacade(HttpServletRequest request, HttpServletResponse response) { + this.request = request; + this.response = response; + } + + protected class RequestFacade implements Request { + @Override + public String getMethod() { + return request.getMethod(); + } + + @Override + public String getURI() { + StringBuffer buf = request.getRequestURL(); + if (request.getQueryString() != null) { + buf.append('?').append(request.getQueryString()); + } + return buf.toString(); + } + + @Override + public boolean isSecure() { + return request.isSecure(); + } + + @Override + public String getFirstParam(String param) { + return request.getParameter(param); + } + + @Override + public String getQueryParamValue(String param) { + if (queryParameters == null) { + queryParameters = UriUtils.decodeQueryString(request.getQueryString()); + } + return queryParameters.getFirst(param); + } + + @Override + public Cookie getCookie(String cookieName) { + if (request.getCookies() == null) return null; + javax.servlet.http.Cookie cookie = null; + for (javax.servlet.http.Cookie c : request.getCookies()) { + if (c.getName().equals(cookieName)) { + cookie = c; + break; + } + } + if (cookie == null) return null; + return new Cookie(cookie.getName(), cookie.getValue(), cookie.getVersion(), cookie.getDomain(), cookie.getPath()); + } + + @Override + public String getHeader(String name) { + return request.getHeader(name); + } + + @Override + public List getHeaders(String name) { + Enumeration values = request.getHeaders(name); + List list = new LinkedList<>(); + while (values.hasMoreElements()) list.add(values.nextElement()); + return list; + } + + @Override + public InputStream getInputStream() { + try { + return request.getInputStream(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public String getRemoteAddr() { + return request.getRemoteAddr(); + } + } + public boolean isEnded() { + return responseFacade.isEnded(); + } + + protected class ResponseFacade implements Response { + protected boolean ended; + + @Override + public void setStatus(int status) { + response.setStatus(status); + } + + @Override + public void addHeader(String name, String value) { + response.addHeader(name, value); + } + + @Override + public void setHeader(String name, String value) { + response.setHeader(name, value); + } + + @Override + public void resetCookie(String name, String path) { + setCookie(name, "", path, null, 0, false, false); + } + + @Override + public void setCookie(String name, String value, String path, String domain, int maxAge, boolean secure, boolean httpOnly) { + StringBuffer cookieBuf = new StringBuffer(); + ServerCookie.appendCookieValue(cookieBuf, 1, name, value, path, domain, null, maxAge, secure, httpOnly); + String cookie = cookieBuf.toString(); + response.addHeader("Set-Cookie", cookie); + } + + @Override + public OutputStream getOutputStream() { + try { + return response.getOutputStream(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public void sendError(int code, String message) { + try { + response.sendError(code, message); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public void end() { + ended = true; + } + + public boolean isEnded() { + return ended; + } + } + + + @Override + public Request getRequest() { + return requestFacade; + } + + @Override + public Response getResponse() { + return responseFacade; + } + + @Override + public X509Certificate[] getCertificateChain() { + throw new IllegalStateException("Not supported yet"); + } +} diff --git a/pom.xml b/pom.xml index 1ad5e1a1c1..46e5433b96 100755 --- a/pom.xml +++ b/pom.xml @@ -762,6 +762,11 @@ keycloak-adapter-spi ${project.version} + + org.keycloak + keycloak-servlet-adapter-spi + ${project.version} + org.keycloak keycloak-adapter-core @@ -887,6 +892,11 @@ keycloak-tomcat6-adapter ${project.version} + + org.keycloak + keycloak-saml-server-filter-adapter + ${project.version} + org.keycloak keycloak-saml-tomcat6-adapter diff --git a/saml/client-adapter/core/src/main/java/org/keycloak/adapters/saml/SamlAuthenticator.java b/saml/client-adapter/core/src/main/java/org/keycloak/adapters/saml/SamlAuthenticator.java index 953c4cf6db..b404f9b7d2 100755 --- a/saml/client-adapter/core/src/main/java/org/keycloak/adapters/saml/SamlAuthenticator.java +++ b/saml/client-adapter/core/src/main/java/org/keycloak/adapters/saml/SamlAuthenticator.java @@ -88,6 +88,9 @@ public abstract class SamlAuthenticator { protected AuthOutcome globalLogout() { SamlSession account = sessionStore.getAccount(); + if (account == null) { + return AuthOutcome.NOT_ATTEMPTED; + } SAML2LogoutRequestBuilder logoutBuilder = new SAML2LogoutRequestBuilder() .assertionExpiration(30) .issuer(deployment.getEntityID()) diff --git a/saml/client-adapter/pom.xml b/saml/client-adapter/pom.xml index 36def452de..1fa9028d4c 100755 --- a/saml/client-adapter/pom.xml +++ b/saml/client-adapter/pom.xml @@ -20,5 +20,6 @@ jetty wildfly as7-eap6 + servlet-filter diff --git a/saml/client-adapter/servlet-filter/pom.xml b/saml/client-adapter/servlet-filter/pom.xml new file mode 100755 index 0000000000..219110c5c8 --- /dev/null +++ b/saml/client-adapter/servlet-filter/pom.xml @@ -0,0 +1,69 @@ + + + + keycloak-parent + org.keycloak + 1.6.0.Final-SNAPSHOT + ../../../pom.xml + + 4.0.0 + + keycloak-saml-server-filter-adapter + Keycloak SAML Servlet Filter + + + + + org.jboss.logging + jboss-logging + + + org.keycloak + keycloak-common + + + org.keycloak + keycloak-adapter-spi + + + org.keycloak + keycloak-servlet-adapter-spi + + + org.bouncycastle + bcprov-jdk15on + + + org.keycloak + keycloak-saml-core + + + org.keycloak + keycloak-saml-adapter-core + + + org.jboss.spec.javax.servlet + jboss-servlet-api_3.0_spec + provided + + + junit + junit + test + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 1.6 + 1.6 + + + + + + diff --git a/saml/client-adapter/servlet-filter/src/main/java/org/keycloak/adapters/saml/servlet/FilterSamlSessionStore.java b/saml/client-adapter/servlet-filter/src/main/java/org/keycloak/adapters/saml/servlet/FilterSamlSessionStore.java new file mode 100755 index 0000000000..dc4cd32d79 --- /dev/null +++ b/saml/client-adapter/servlet-filter/src/main/java/org/keycloak/adapters/saml/servlet/FilterSamlSessionStore.java @@ -0,0 +1,218 @@ +package org.keycloak.adapters.saml.servlet; + +import org.jboss.logging.Logger; +import org.keycloak.adapters.HttpFacade; +import org.keycloak.adapters.SessionIdMapper; +import org.keycloak.adapters.saml.SamlSession; +import org.keycloak.adapters.saml.SamlSessionStore; +import org.keycloak.adapters.servlet.FilterSessionStore; +import org.keycloak.util.MultivaluedHashMap; + +import javax.servlet.ServletInputStream; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletRequestWrapper; +import javax.servlet.http.HttpSession; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.security.Principal; +import java.util.Collections; +import java.util.Enumeration; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; + +/** + * @author Bill Burke + * @version $Revision: 1 $ + */ +public class FilterSamlSessionStore extends FilterSessionStore implements SamlSessionStore { + protected static Logger log = Logger.getLogger(SamlSessionStore.class); + protected final SessionIdMapper idMapper; + + public FilterSamlSessionStore(HttpServletRequest request, HttpFacade facade, int maxBuffer, SessionIdMapper idMapper) { + super(request, facade, maxBuffer); + this.idMapper = idMapper; + } + + protected boolean needRequestRestore; + + @Override + public void logoutAccount() { + HttpSession session = request.getSession(false); + if (session == null) return; + if (session != null) { + if (idMapper != null) idMapper.removeSession(session.getId()); + SamlSession samlSession = (SamlSession)session.getAttribute(SamlSession.class.getName()); + if (samlSession != null) { + session.removeAttribute(SamlSession.class.getName()); + } + clearSavedRequest(session); + } + } + + @Override + public void logoutByPrincipal(String principal) { + SamlSession account = getAccount(); + if (account != null && account.getPrincipal().getSamlSubject().equals(principal)) { + logoutAccount(); + } + if (idMapper != null) { + Set sessions = idMapper.getUserSessions(principal); + if (sessions != null) { + List ids = new LinkedList(); + ids.addAll(sessions); + for (String id : ids) { + idMapper.removeSession(id); + } + } + } + + } + + @Override + public void logoutBySsoId(List ssoIds) { + SamlSession account = getAccount(); + for (String ssoId : ssoIds) { + if (account != null && account.getSessionIndex().equals(ssoId)) { + logoutAccount(); + } else if (idMapper != null) { + String sessionId = idMapper.getSessionFromSSO(ssoId); + idMapper.removeSession(sessionId); + } + } + } + + @Override + public boolean isLoggedIn() { + HttpSession session = request.getSession(false); + if (session == null) return false; + if (session == null) { + log.debug("session was null, returning null"); + return false; + } + final SamlSession samlSession = (SamlSession)session.getAttribute(SamlSession.class.getName()); + if (samlSession == null) { + log.debug("SamlSession was not in session, returning null"); + return false; + } + if (idMapper != null && !idMapper.hasSession(session.getId())) { + logoutAccount(); + return false; + } + + needRequestRestore = restoreRequest(); + return true; + } + + public HttpServletRequestWrapper getWrap() { + HttpSession session = request.getSession(true); + final SamlSession samlSession = (SamlSession)session.getAttribute(SamlSession.class.getName()); + if (needRequestRestore) { + final String method = (String)session.getAttribute(SAVED_METHOD); + final byte[] body = (byte[])session.getAttribute(SAVED_BODY); + final MultivaluedHashMap headers = (MultivaluedHashMap)session.getAttribute(SAVED_HEADERS); + clearSavedRequest(session); + HttpServletRequestWrapper wrapper = new HttpServletRequestWrapper(request) { + @Override + public boolean isUserInRole(String role) { + return samlSession.getRoles().contains(role); + } + + @Override + public Principal getUserPrincipal() { + return samlSession.getPrincipal(); + } + + @Override + public String getMethod() { + if (needRequestRestore) { + return method; + } else { + return super.getMethod(); + + } + } + + @Override + public String getHeader(String name) { + if (needRequestRestore && headers != null) { + return headers.getFirst(name); + } + return super.getHeader(name); + } + + @Override + public Enumeration getHeaders(String name) { + if (needRequestRestore && headers != null) { + List values = headers.getList(name); + if (values == null) return Collections.emptyEnumeration(); + else return Collections.enumeration(values); + } + return super.getHeaders(name); + } + + @Override + public Enumeration getHeaderNames() { + if (needRequestRestore && headers != null) { + return Collections.enumeration(headers.keySet()); + } + return super.getHeaderNames(); + } + + @Override + public ServletInputStream getInputStream() throws IOException { + + if (needRequestRestore && body != null) { + final ByteArrayInputStream is = new ByteArrayInputStream(body); + return new ServletInputStream() { + @Override + public int read() throws IOException { + return is.read(); + } + }; + } + return super.getInputStream(); + } + }; + return wrapper; + } else { + return new HttpServletRequestWrapper(request) { + @Override + public boolean isUserInRole(String role) { + return samlSession.getRoles().contains(role); + } + + @Override + public Principal getUserPrincipal() { + return samlSession.getPrincipal(); + } + + }; + } + + + + } + + @Override + public void saveAccount(SamlSession account) { + HttpSession session = request.getSession(true); + session.setAttribute(SamlSession.class.getName(), account); + if (idMapper != null) idMapper.map(account.getSessionIndex(), account.getPrincipal().getSamlSubject(), session.getId()); + } + + @Override + public SamlSession getAccount() { + HttpSession session = request.getSession(false); + if (session == null) return null; + return (SamlSession)session.getAttribute(SamlSession.class.getName()); + } + + @Override + public String getRedirectUri() { + HttpSession session = request.getSession(false); + if (session == null) return null; + return (String)session.getAttribute(REDIRECT_URI); + } + +} diff --git a/saml/client-adapter/servlet-filter/src/main/java/org/keycloak/adapters/saml/servlet/SamlFilter.java b/saml/client-adapter/servlet-filter/src/main/java/org/keycloak/adapters/saml/servlet/SamlFilter.java new file mode 100755 index 0000000000..c1b6ad555d --- /dev/null +++ b/saml/client-adapter/servlet-filter/src/main/java/org/keycloak/adapters/saml/servlet/SamlFilter.java @@ -0,0 +1,147 @@ +package org.keycloak.adapters.saml.servlet; + +import org.keycloak.adapters.AuthChallenge; +import org.keycloak.adapters.AuthOutcome; +import org.keycloak.adapters.InMemorySessionIdMapper; +import org.keycloak.adapters.SessionIdMapper; +import org.keycloak.adapters.saml.DefaultSamlDeployment; +import org.keycloak.adapters.saml.SamlAuthenticator; +import org.keycloak.adapters.saml.SamlDeployment; +import org.keycloak.adapters.saml.SamlDeploymentContext; +import org.keycloak.adapters.saml.SamlSession; +import org.keycloak.adapters.saml.config.parsers.DeploymentBuilder; +import org.keycloak.adapters.saml.config.parsers.ResourceLoader; +import org.keycloak.adapters.servlet.ServletHttpFacade; +import org.keycloak.saml.common.exceptions.ParsingException; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.RequestDispatcher; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletRequestWrapper; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.InputStream; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * @author Bill Burke + * @version $Revision: 1 $ + */ +public class SamlFilter implements Filter { + protected SamlDeploymentContext deploymentContext; + protected SessionIdMapper idMapper = new InMemorySessionIdMapper(); + private final static Logger log = Logger.getLogger(""+SamlFilter.class); + + @Override + public void init(final FilterConfig filterConfig) throws ServletException { + String configResolverClass = filterConfig.getInitParameter("keycloak.config.resolver"); + if (configResolverClass != null) { + try { + throw new RuntimeException("Not implemented yet"); + //KeycloakConfigResolver configResolver = (KeycloakConfigResolver) context.getLoader().getClassLoader().loadClass(configResolverClass).newInstance(); + //deploymentContext = new SamlDeploymentContext(configResolver); + //log.log(Level.INFO, "Using {0} to resolve Keycloak configuration on a per-request basis.", configResolverClass); + } catch (Exception ex) { + log.log(Level.FINE, "The specified resolver {0} could NOT be loaded. Keycloak is unconfigured and will deny all requests. Reason: {1}", new Object[]{configResolverClass, ex.getMessage()}); + //deploymentContext = new AdapterDeploymentContext(new KeycloakDeployment()); + } + } else { + String path = "/WEB-INF/keycloak-saml.xml"; + String pathParam = filterConfig.getInitParameter("keycloak.config.file"); + if (pathParam != null) path = pathParam; + InputStream is = filterConfig.getServletContext().getResourceAsStream(path); + final SamlDeployment deployment; + if (is == null) { + log.info("No adapter configuration. Keycloak is unconfigured and will deny all requests."); + deployment = new DefaultSamlDeployment(); + } else { + try { + ResourceLoader loader = new ResourceLoader() { + @Override + public InputStream getResourceAsStream(String resource) { + return filterConfig.getServletContext().getResourceAsStream(resource); + } + }; + deployment = new DeploymentBuilder().build(is, loader); + } catch (ParsingException e) { + throw new RuntimeException(e); + } + } + deploymentContext = new SamlDeploymentContext(deployment); + log.fine("Keycloak is using a per-deployment configuration."); + } + filterConfig.getServletContext().setAttribute(SamlDeploymentContext.class.getName(), deploymentContext); + + } + + @Override + public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { + HttpServletRequest request = (HttpServletRequest) req; + HttpServletResponse response = (HttpServletResponse) res; + ServletHttpFacade facade = new ServletHttpFacade(request, response); + SamlDeployment deployment = deploymentContext.resolveDeployment(facade); + if (deployment == null || !deployment.isConfigured()) { + response.sendError(403); + log.fine("deployment not configured"); + return; + } + FilterSamlSessionStore tokenStore = new FilterSamlSessionStore(request, facade, 100000, idMapper); + + + SamlAuthenticator authenticator = new SamlAuthenticator(facade, deployment, tokenStore) { + @Override + protected void completeAuthentication(SamlSession account) { + + } + }; + AuthOutcome outcome = authenticator.authenticate(); + if (outcome == AuthOutcome.AUTHENTICATED) { + log.fine("AUTHENTICATED"); + if (facade.isEnded()) { + return; + } + HttpServletRequestWrapper wrapper = tokenStore.getWrap(); + chain.doFilter(wrapper, res); + return; + } + if (outcome == AuthOutcome.LOGGED_OUT) { + tokenStore.logoutAccount(); + if (deployment.getLogoutPage() != null) { + RequestDispatcher disp = req.getRequestDispatcher(deployment.getLogoutPage()); + disp.forward(req, res); + return; + } + chain.doFilter(req, res); + return; + } + + AuthChallenge challenge = authenticator.getChallenge(); + if (challenge != null) { + log.fine("challenge"); + if (challenge.errorPage()) { + response.sendError(403); + return; + } + log.fine("sending challenge"); + challenge.challenge(facade); + } + if (outcome == AuthOutcome.FAILED) { + response.sendError(403); + } else if (!facade.isEnded()) { + chain.doFilter(req, res); + return; + } + + } + + @Override + public void destroy() { + + } +} diff --git a/testsuite/integration/pom.xml b/testsuite/integration/pom.xml index 979d65e94f..7587247b8b 100755 --- a/testsuite/integration/pom.xml +++ b/testsuite/integration/pom.xml @@ -105,6 +105,10 @@ org.keycloak keycloak-saml-adapter-core + + org.keycloak + keycloak-saml-server-filter-adapter + org.keycloak keycloak-saml-undertow-adapter diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/keycloaksaml/SendUsernameServlet.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/keycloaksaml/SendUsernameServlet.java index faa50146a9..f3a14f4939 100755 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/keycloaksaml/SendUsernameServlet.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/keycloaksaml/SendUsernameServlet.java @@ -26,7 +26,11 @@ public class SendUsernameServlet extends HttpServlet { if (checkRoles != null) { for (String role : checkRoles) { System.out.println("check role: " + role); - Assert.assertTrue(req.isUserInRole(role)); + //Assert.assertTrue(req.isUserInRole(role)); + if (!req.isUserInRole(role)) { + resp.sendError(403); + return; + } } } diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/samlfilter/SamlAdapterTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/samlfilter/SamlAdapterTest.java new file mode 100755 index 0000000000..88f97a6e01 --- /dev/null +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/samlfilter/SamlAdapterTest.java @@ -0,0 +1,147 @@ +package org.keycloak.testsuite.samlfilter; + +import org.junit.Assert; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.keycloak.testsuite.keycloaksaml.SamlAdapterTestStrategy; +import org.keycloak.testsuite.keycloaksaml.SamlSPFacade; +import org.keycloak.testsuite.keycloaksaml.SendUsernameServlet; +import org.openqa.selenium.WebDriver; + +import java.util.LinkedList; +import java.util.List; + +/** + * @author Bill Burke + * @version $Revision: 1 $ + */ +public class SamlAdapterTest { + + @ClassRule + public static SamlKeycloakRule keycloakRule = new SamlKeycloakRule() { + @Override + public void initWars() { + ClassLoader classLoader = SamlAdapterTest.class.getClassLoader(); + + initializeSamlSecuredWar("/keycloak-saml/simple-post", "/sales-post", "post.war", classLoader); + initializeSamlSecuredWar("/keycloak-saml/signed-post", "/sales-post-sig", "post-sig.war", classLoader); + initializeSamlSecuredWar("/keycloak-saml/signed-post-email", "/sales-post-sig-email", "post-sig-email.war", classLoader); + initializeSamlSecuredWar("/keycloak-saml/signed-post-transient", "/sales-post-sig-transient", "post-sig-transient.war", classLoader); + initializeSamlSecuredWar("/keycloak-saml/signed-post-persistent", "/sales-post-sig-persistent", "post-sig-persistent.war", classLoader); + initializeSamlSecuredWar("/keycloak-saml/signed-metadata", "/sales-metadata", "post-metadata.war", classLoader); + initializeSamlSecuredWar("/keycloak-saml/signed-get", "/employee-sig", "employee-sig.war", classLoader); + initializeSamlSecuredWar("/keycloak-saml/mappers", "/employee2", "employee2.war", classLoader); + initializeSamlSecuredWar("/keycloak-saml/signed-front-get", "/employee-sig-front", "employee-sig-front.war", classLoader); + initializeSamlSecuredWar("/keycloak-saml/bad-client-signed-post", "/bad-client-sales-post-sig", "bad-client-post-sig.war", classLoader); + initializeSamlSecuredWar("/keycloak-saml/bad-realm-signed-post", "/bad-realm-sales-post-sig", "bad-realm-post-sig.war", classLoader); + initializeSamlSecuredWar("/keycloak-saml/encrypted-post", "/sales-post-enc", "post-enc.war", classLoader); + SamlAdapterTestStrategy.uploadSP("http://localhost:8081/auth"); + + + + } + + @Override + public String getRealmJson() { + return "/keycloak-saml/testsaml.json"; + } + }; + + @Rule + public SamlAdapterTestStrategy testStrategy = new SamlAdapterTestStrategy("http://localhost:8081/auth", "http://localhost:8081", keycloakRule); + + @Test + public void testPostBadRealmSignature() { + testStrategy.testPostBadRealmSignature( new SamlAdapterTestStrategy.CheckAuthError() { + @Override + public void check(WebDriver driver) { + Assert.assertTrue(driver.getPageSource().contains("Forbidden")); + } + }); + } + + @Test + public void testPostSimpleUnauthorized() { + List requiredRoles = new LinkedList<>(); + requiredRoles.add("manager"); + requiredRoles.add("employee"); + requiredRoles.add("user"); + SendUsernameServlet.checkRoles = requiredRoles; + try { + testStrategy.testPostSimpleUnauthorized(new SamlAdapterTestStrategy.CheckAuthError() { + @Override + public void check(WebDriver driver) { + Assert.assertTrue(driver.getPageSource().contains("Forbidden")); + } + }); + } finally { + SendUsernameServlet.checkRoles = null; + } + } + + @Test + public void testMetadataPostSignedLoginLogout() throws Exception { + testStrategy.testMetadataPostSignedLoginLogout(); + } + + @Test + public void testRedirectSignedLoginLogout() { + testStrategy.testRedirectSignedLoginLogout(); + } + + @Test + public void testPostSignedLoginLogoutEmailNameID() { + testStrategy.testPostSignedLoginLogoutEmailNameID(); + } + + @Test + public void testPostEncryptedLoginLogout() { + testStrategy.testPostEncryptedLoginLogout(); + } + + @Test + public void testRedirectSignedLoginLogoutFrontNoSSO() { + testStrategy.testRedirectSignedLoginLogoutFrontNoSSO(); + } + + @Test + public void testPostSimpleLoginLogout() { + testStrategy.testPostSimpleLoginLogout(); + } + + @Test + public void testPostSignedLoginLogoutTransientNameID() { + testStrategy.testPostSignedLoginLogoutTransientNameID(); + } + + @Test + public void testPostSimpleLoginLogoutIdpInitiated() { + testStrategy.testPostSimpleLoginLogoutIdpInitiated(); + } + + @Test + public void testAttributes() throws Exception { + testStrategy.testAttributes(); + } + + @Test + public void testPostSignedLoginLogoutPersistentNameID() { + testStrategy.testPostSignedLoginLogoutPersistentNameID(); + } + + @Test + public void testPostBadClientSignature() { + testStrategy.testPostBadClientSignature(); + } + + @Test + public void testRedirectSignedLoginLogoutFront() { + testStrategy.testRedirectSignedLoginLogoutFront(); + } + + @Test + public void testPostSignedLoginLogout() { + testStrategy.testPostSignedLoginLogout(); + } +} diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/samlfilter/SamlKeycloakRule.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/samlfilter/SamlKeycloakRule.java new file mode 100755 index 0000000000..e1fd3c85ff --- /dev/null +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/samlfilter/SamlKeycloakRule.java @@ -0,0 +1,125 @@ +package org.keycloak.testsuite.samlfilter; + +import io.undertow.security.idm.Account; +import io.undertow.security.idm.Credential; +import io.undertow.security.idm.IdentityManager; +import io.undertow.server.handlers.resource.Resource; +import io.undertow.server.handlers.resource.ResourceChangeListener; +import io.undertow.server.handlers.resource.ResourceManager; +import io.undertow.server.handlers.resource.URLResource; +import io.undertow.servlet.api.DeploymentInfo; +import io.undertow.servlet.api.FilterInfo; +import io.undertow.servlet.api.LoginConfig; +import io.undertow.servlet.api.SecurityConstraint; +import io.undertow.servlet.api.ServletInfo; +import io.undertow.servlet.api.WebResourceCollection; +import org.keycloak.adapters.saml.servlet.SamlFilter; +import org.keycloak.adapters.saml.undertow.SamlServletExtension; +import org.keycloak.testsuite.keycloaksaml.SendUsernameServlet; +import org.keycloak.testsuite.rule.AbstractKeycloakRule; + +import javax.servlet.DispatcherType; +import java.io.IOException; +import java.net.URL; + +/** + * @author Bill Burke + * @version $Revision: 1 $ + */ +public abstract class SamlKeycloakRule extends AbstractKeycloakRule { + + public static class TestResourceManager implements ResourceManager { + + private final String basePath; + + public TestResourceManager(String basePath){ + this.basePath = basePath; + } + + @Override + public Resource getResource(String path) throws IOException { + String temp = path; + String fullPath = basePath + temp; + URL url = getClass().getResource(fullPath); + if (url == null) { + System.out.println("url is null: " + fullPath); + } + return new URLResource(url, url.openConnection(), path); + } + + @Override + public boolean isResourceChangeListenerSupported() { + throw new RuntimeException(); + } + + @Override + public void registerResourceChangeListener(ResourceChangeListener listener) { + throw new RuntimeException(); + } + + @Override + public void removeResourceChangeListener(ResourceChangeListener listener) { + throw new RuntimeException(); + } + + @Override + public void close() throws IOException { + throw new RuntimeException(); + } + } + + public static class TestIdentityManager implements IdentityManager { + @Override + public Account verify(Account account) { + return account; + } + + @Override + public Account verify(String userName, Credential credential) { + throw new RuntimeException("WTF"); + } + + @Override + public Account verify(Credential credential) { + throw new RuntimeException(); + } + } + + @Override + protected void setupKeycloak() { + String realmJson = getRealmJson(); + server.importRealm(getClass().getResourceAsStream(realmJson)); + initWars(); + } + + public abstract void initWars(); + + public void initializeSamlSecuredWar(String warResourcePath, String contextPath, String warDeploymentName, ClassLoader classLoader) { + + ServletInfo regularServletInfo = new ServletInfo("servlet", SendUsernameServlet.class) + .addMapping("/*"); + + FilterInfo samlFilter = new FilterInfo("saml-filter", SamlFilter.class); + + + ResourceManager resourceManager = new TestResourceManager(warResourcePath); + + DeploymentInfo deploymentInfo = new DeploymentInfo() + .setClassLoader(classLoader) + .setIdentityManager(new TestIdentityManager()) + .setContextPath(contextPath) + .setDeploymentName(warDeploymentName) + .setResourceManager(resourceManager) + .addServlets(regularServletInfo) + .addFilter(samlFilter) + .addFilterUrlMapping("saml-filter", "/*", DispatcherType.REQUEST) + .addServletExtension(new SamlServletExtension()); + server.getServer().deploy(deploymentInfo); + } + + public String getRealmJson() { + return "/keycloak-saml/testsaml.json"; + } + + +}