diff --git a/services/src/main/java/org/keycloak/protocol/saml/SamlService.java b/services/src/main/java/org/keycloak/protocol/saml/SamlService.java index 18b2593eec..76d15ab7da 100755 --- a/services/src/main/java/org/keycloak/protocol/saml/SamlService.java +++ b/services/src/main/java/org/keycloak/protocol/saml/SamlService.java @@ -41,6 +41,7 @@ import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.ClientModel; import org.keycloak.models.KeyManager; import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakUriInfo; import org.keycloak.models.RealmModel; import org.keycloak.models.UserSessionModel; import org.keycloak.protocol.AuthorizationEndpointBase; @@ -55,6 +56,7 @@ import org.keycloak.saml.common.constants.GeneralConstants; import org.keycloak.saml.common.constants.JBossSAMLURIConstants; import org.keycloak.saml.processing.core.saml.v2.common.SAMLDocumentHolder; import org.keycloak.services.ErrorPage; +import org.keycloak.services.Urls; import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.messages.Messages; import org.keycloak.services.resources.RealmsResource; @@ -92,7 +94,6 @@ import org.keycloak.saml.processing.core.util.KeycloakKeySamlExtensionGenerator; import org.keycloak.saml.validators.DestinationValidator; import org.keycloak.sessions.AuthenticationSessionModel; import java.nio.charset.StandardCharsets; -import java.util.logging.Level; /** * Resource class for the saml connect token service @@ -151,7 +152,7 @@ public class SamlService extends AuthorizationEndpointBase { StatusResponseType statusResponse = (StatusResponseType) holder.getSamlObject(); // validate destination - if (! destinationValidator.validate(session.getContext().getUri().getAbsolutePath(), statusResponse.getDestination())) { + if (! destinationValidator.validate(this.getExpectedDestinationUri(session), statusResponse.getDestination())) { event.detail(Details.REASON, "invalid_destination"); event.error(Errors.INVALID_SAML_LOGOUT_RESPONSE); return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST); @@ -288,7 +289,7 @@ public class SamlService extends AuthorizationEndpointBase { event.error(Errors.INVALID_SAML_AUTHN_REQUEST); return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST); } - if (! destinationValidator.validate(session.getContext().getUri().getAbsolutePath(), requestAbstractType.getDestination())) { + if (! destinationValidator.validate(this.getExpectedDestinationUri(session), requestAbstractType.getDestination())) { event.detail(Details.REASON, "invalid_destination"); event.error(Errors.INVALID_SAML_AUTHN_REQUEST); return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST); @@ -402,7 +403,7 @@ public class SamlService extends AuthorizationEndpointBase { event.error(Errors.INVALID_SAML_LOGOUT_REQUEST); return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST); } - if (! destinationValidator.validate(logoutRequest.getDestination(), session.getContext().getUri().getAbsolutePath())) { + if (! destinationValidator.validate(this.getExpectedDestinationUri(session), logoutRequest.getDestination())) { event.detail(Details.REASON, "invalid_destination"); event.error(Errors.INVALID_SAML_LOGOUT_REQUEST); return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST); @@ -517,6 +518,18 @@ public class SamlService extends AuthorizationEndpointBase { else return handleSamlResponse(samlResponse, relayState); } + + /** + * KEYCLOAK-12616, KEYCLOAK-12944: construct the expected destination URI using the configured base URI. + * + * @param session a reference to the {@link KeycloakSession}. + * @return the constructed {@link URI}. + */ + protected URI getExpectedDestinationUri(final KeycloakSession session) { + final String realmName = session.getContext().getRealm().getName(); + final URI baseUri = session.getContext().getUri().getBaseUri(); + return Urls.samlRequestEndpoint(baseUri, realmName); + } } protected class PostBindingProtocol extends BindingProtocol { diff --git a/services/src/main/java/org/keycloak/services/Urls.java b/services/src/main/java/org/keycloak/services/Urls.java index 49cb044405..5630ed2135 100755 --- a/services/src/main/java/org/keycloak/services/Urls.java +++ b/services/src/main/java/org/keycloak/services/Urls.java @@ -20,6 +20,7 @@ import org.keycloak.common.Version; import org.keycloak.models.Constants; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocolService; +import org.keycloak.protocol.saml.SamlProtocol; import org.keycloak.services.resources.account.AccountFormService; import org.keycloak.services.resources.IdentityBrokerService; import org.keycloak.services.resources.LoginActionsService; @@ -268,4 +269,8 @@ public class Urls { private static UriBuilder themeBase(URI baseUri) { return UriBuilder.fromUri(baseUri).path(ThemeResource.class); } + + public static URI samlRequestEndpoint(final URI baseUri, final String realmName) { + return realmBase(baseUri).path(RealmsResource.class, "getProtocol").build(realmName, SamlProtocol.LOGIN_PROTOCOL); + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/SamlReverseProxyTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/SamlReverseProxyTest.java new file mode 100644 index 0000000000..16d2f66251 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/SamlReverseProxyTest.java @@ -0,0 +1,146 @@ +/* + * Copyright 2020 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.testsuite.saml; + +import java.net.URI; +import java.util.HashMap; + +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriBuilder; + +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.util.EntityUtils; +import org.hamcrest.Matcher; +import org.junit.ClassRule; +import org.junit.Test; +import org.keycloak.protocol.saml.SamlProtocol; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.saml.SAML2LogoutRequestBuilder; +import org.keycloak.saml.processing.api.saml.v2.request.SAML2Request; +import org.keycloak.services.resources.RealmsResource; +import org.keycloak.testsuite.util.ReverseProxy; +import org.keycloak.testsuite.util.SamlClient; +import org.w3c.dom.Document; + +import static org.hamcrest.Matchers.containsString; +import static org.junit.Assert.assertThat; +import static org.keycloak.testsuite.util.Matchers.statusCodeIsHC; + +/** + * SAML tests using a {@code frontendUrl} that points to a reverse proxy. The SAML request destination should be validated + * against the proxy address and any redirection should also have the proxy as target. + * + * @author Stefan Guilhen + */ +public class SamlReverseProxyTest extends AbstractSamlTest { + + @ClassRule + public static ReverseProxy proxy = new ReverseProxy(); + + /** + * KEYCLOAK-12612 + * + * Tests sending a SAML {@code AuthnRequest} through a reverse proxy. In this scenario the SAML {@code AuthnRequest} + * has a destination that matches the proxy server, but the request is forwarded to a keycloak server running in a + * different address. + * + * Validation of the destination and subsequent redirection to the login screen only work if the proxy server is configured + * as the {@code frontendUrl} of the realm. + * + * @throws Exception if an error occurs while running the test. + */ + @Test + public void testAuthnRequestWithReverseProxy() throws Exception { + // send an authn request without defining the frontendUrl for the realm - should get a BAD_REQUEST response + Document document = SAML2Request.convert(SamlClient.createLoginRequestDocument(SAML_CLIENT_ID_SALES_POST, + SAML_ASSERTION_CONSUMER_URL_SALES_POST, this.buildSamlProtocolUrl(proxy.getUrl()))); + testSendSamlRequest(document, Response.Status.BAD_REQUEST, containsString("Invalid Request")); + + // set the frontendUrl pointing to the reverse proxy + RealmRepresentation rep = adminClient.realm(REALM_NAME).toRepresentation(); + try { + if (rep.getAttributes() == null) { + rep.setAttributes(new HashMap<>()); + } + rep.getAttributes().put("frontendUrl", proxy.getUrl()); + adminClient.realm(REALM_NAME).update(rep); + + // resend the authn request - should succeed this time + testSendSamlRequest(document, Response.Status.OK, containsString("login")); + } finally { + // restore the state of the realm (unset the frontendUrl) + rep.getAttributes().remove("frontendUrl"); + adminClient.realm(REALM_NAME).update(rep); + } + } + + /** + * KEYCLOAK-12944 + * + * Tests sending a SAML {@code LogoutRequest} through a reverse proxy. In this scenario the SAML {@code LogoutRequest} + * has a destination that matches the proxy server, but the request is forwarded to a keycloak server running in a + * different address. + * + * Validation of the destination and any subsequent redirection only work if the proxy server is configured as the + * {@code frontendUrl} of the realm. + * + * @throws Exception if an error occurs while running the test. + */ + @Test + public void testLogoutRequestWithReverseProxy() throws Exception { + // send a logout request without defining the frontendUrl for the realm - should get a BAD_REQUEST response + Document document = new SAML2LogoutRequestBuilder().destination( + this.buildSamlProtocolUrl(proxy.getUrl()).toString()).issuer(SAML_CLIENT_ID_SALES_POST).buildDocument(); + testSendSamlRequest(document, Response.Status.BAD_REQUEST, containsString("Invalid Request")); + + // set the frontendUrl pointing to the reverse proxy + RealmRepresentation rep = adminClient.realm(REALM_NAME).toRepresentation(); + try { + if (rep.getAttributes() == null) { + rep.setAttributes(new HashMap<>()); + } + rep.getAttributes().put("frontendUrl", proxy.getUrl()); + adminClient.realm(REALM_NAME).update(rep); + + // resend the logout request - should succeed this time (we are actually not logging out anyone, just checking the request is properly validated + testSendSamlRequest(document, Response.Status.OK, containsString("login")); + } finally { + // restore the state of the realm (unset the frontendUrl) + rep.getAttributes().remove("frontendUrl"); + adminClient.realm(REALM_NAME).update(rep); + } + } + + private void testSendSamlRequest(final Document doc, final Response.Status expectedHttpCode, final Matcher pageTextMatcher) throws Exception { + HttpUriRequest post = + SamlClient.Binding.POST.createSamlUnsignedRequest(this.buildSamlProtocolUrl(proxy.getUrl()), null, doc); + try (CloseableHttpClient client = HttpClientBuilder.create().setSSLHostnameVerifier((s, sslSession) -> true). + setRedirectStrategy(new SamlClient.RedirectStrategyWithSwitchableFollowRedirect()).build(); + CloseableHttpResponse response = client.execute(post)) { + assertThat(response, statusCodeIsHC(expectedHttpCode)); + assertThat(EntityUtils.toString(response.getEntity(), "UTF-8"), pageTextMatcher); + } + } + + private URI buildSamlProtocolUrl(final String baseUri) { + return RealmsResource.protocolUrl(UriBuilder.fromUri(baseUri)).build(REALM_NAME, SamlProtocol.LOGIN_PROTOCOL); + } + +}