Fixes to hostname (#10820)

Closes #10627
Closes #10331
This commit is contained in:
Pedro Igor 2022-03-22 04:11:50 -03:00 committed by GitHub
parent 2394855f48
commit ffa6df5547
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 5165 additions and 50 deletions

View file

@ -27,6 +27,11 @@ To set the hostname part of the frontend base URL, enter this command:
<@kc.start parameters="--hostname=<value>"/>
You can also set a different port if your proxy is exposing the frontend URL using a port other than the default HTTP (80) and HTTPS(443) ports. For that,
set the `hostname-port` option.
<@kc.start parameters="--hostname=<value> --hostname-port=<port>"/>
=== Backend Endpoints
Backend endpoints are used for direct communication between Keycloak and applications.
Examples of backend endpoints are the Token endpoint and the User info endpoint.
@ -39,11 +44,6 @@ When all applications connected to Keycloak communicate through the public URL,
Otherwise, leave this parameter as false to allow internal applications to communicate with Keycloak through an internal URL.
=== Administrative Endpoints
When the Admin Console is exposed on a different hostname, use `--hostname-admin` to link to it as shown in this example:
<@kc.start parameters="--hostname=<hostname> --hostname-admin=<adminHostname>"/>
When `hostname-admin` is configured, all links and static resources used to render the Admin Console are served from the value you enter for `<adminHostname>` instead of the value for `<hostname>`.
To reduce attack surface, the administration endpoints for Keycloak and the Admin Console should not be publicly accessible.
Therefore, you can secure them by using a reverse proxy.

View file

@ -13,9 +13,6 @@ For Keycloak, your choice of proxy modes depends on the TLS termination in your
== Proxy modes
The following proxy modes are available:
none:: Disables proxy support.
It is the default mode.
edge:: Enables communication through HTTP between the proxy and Keycloak.
This mode is suitable for deployments with a highly secure internal network where the reverse proxy keeps a secure connection (HTTP over TLS) with clients while communicating with Keycloak using HTTP.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -212,13 +212,13 @@ public class KeycloakDeploymentE2EIT extends ClusterOperatorTest {
Awaitility.await()
.ignoreExceptions()
.untilAsserted(() -> {
String url = "https://" + service.getName() + "." + namespace + ":" + Constants.KEYCLOAK_HTTPS_PORT;
String url = "https://" + service.getName() + "." + namespace + ":" + Constants.KEYCLOAK_HTTPS_PORT + "/admin/master/console/";
Log.info("Checking url: " + url);
var curlOutput = K8sUtils.inClusterCurl(k8sclient, namespace, "--insecure", "-H", "Host: foo.bar", url);
var curlOutput = K8sUtils.inClusterCurl(k8sclient, namespace, "-s", "--insecure", "-H", "Host: foo.bar", url);
Log.info("Curl Output: " + curlOutput);
assertTrue(curlOutput.contains("<a href=\"https://example.com:8443/admin/\">"));
assertTrue(curlOutput.contains("var authServerUrl = 'https://example.com:8443';"));
});
} catch (Exception e) {
savePodLogs();
@ -237,13 +237,13 @@ public class KeycloakDeploymentE2EIT extends ClusterOperatorTest {
Awaitility.await()
.ignoreExceptions()
.untilAsserted(() -> {
String url = "https://" + service.getName() + "." + namespace + ":" + Constants.KEYCLOAK_HTTPS_PORT;
String url = "https://" + service.getName() + "." + namespace + ":" + Constants.KEYCLOAK_HTTPS_PORT + "/admin/master/console/";
Log.info("Checking url: " + url);
var curlOutput = K8sUtils.inClusterCurl(k8sclient, namespace, "--insecure", "-H", "Host: foo.bar", url);
var curlOutput = K8sUtils.inClusterCurl(k8sclient, namespace, "-s", "--insecure", "-H", "Host: foo.bar", url);
Log.info("Curl Output: " + curlOutput);
assertTrue(curlOutput.contains("<a href=\"https://foo.bar:8443/admin/\">"));
assertTrue(curlOutput.contains("var authServerUrl = 'https://foo.bar:8443';"));
});
} catch (Exception e) {
savePodLogs();

View file

@ -12,11 +12,6 @@ final class HostnamePropertyMappers {
.description("Hostname for the Keycloak server.")
.paramLabel("hostname")
.build(),
builder().from("hostname-admin")
.to("kc.spi-hostname-default-admin")
.description("Overrides the hostname for the admin console and APIs.")
.paramLabel("url")
.build(),
builder().from("hostname-strict")
.to("kc.spi-hostname-default-strict")
.description("Disables dynamically resolving the hostname from request headers. Should always be set to true in production, unless proxy verifies the Host header.")
@ -39,6 +34,12 @@ final class HostnamePropertyMappers {
.to("kc.spi-hostname-default-path")
.description("This should be set if proxy uses a different context-path for Keycloak.")
.paramLabel("path")
.build(),
builder().from("hostname-port")
.to("kc.spi-hostname-default-hostname-port")
.defaultValue("-1")
.description("The port used by the proxy when exposing the hostname. Set this option if the proxy uses a port other than the default HTTP and HTTPS ports.")
.paramLabel("port")
.build()
};
}

View file

@ -11,7 +11,7 @@ import org.keycloak.quarkus.runtime.Messages;
final class ProxyPropertyMappers {
private static final String[] possibleProxyValues = {"none", "edge", "reencrypt", "passthrough"};
private static final String[] possibleProxyValues = {"edge", "reencrypt", "passthrough"};
private ProxyPropertyMappers(){}

View file

@ -51,6 +51,8 @@ public final class DefaultHostnameProvider implements HostnameProvider, Hostname
private String adminHostName;
private Boolean strictBackChannel;
private boolean hostnameEnabled;
private boolean strictHttps;
private int hostnamePort;
@Override
public String getScheme(UriInfo originalUriInfo, UrlType urlType) {
@ -60,6 +62,10 @@ public final class DefaultHostnameProvider implements HostnameProvider, Hostname
return scheme;
}
if (ADMIN.equals(urlType)) {
return getScheme(originalUriInfo);
}
return fromFrontChannel(originalUriInfo, URI::getScheme, this::getScheme, defaultHttpScheme);
}
@ -71,9 +77,8 @@ public final class DefaultHostnameProvider implements HostnameProvider, Hostname
return hostname;
}
// admin hostname has precedence over frontchannel
if (ADMIN.equals(urlType) && adminHostName != null) {
return adminHostName;
if (ADMIN.equals(urlType)) {
return getHostname(originalUriInfo);
}
return fromFrontChannel(originalUriInfo, URI::getHost, this::getHostname, frontChannelHostName);
@ -97,6 +102,10 @@ public final class DefaultHostnameProvider implements HostnameProvider, Hostname
@Override
public int getPort(UriInfo originalUriInfo, UrlType urlType) {
if (ADMIN.equals(urlType)) {
return getRequestPort();
}
Integer port = forNonStrictBackChannel(originalUriInfo, urlType, this::getPort, this::getPort);
if (port != null) {
@ -105,17 +114,15 @@ public final class DefaultHostnameProvider implements HostnameProvider, Hostname
if (hostnameEnabled && !noProxy) {
// if proxy is enabled and hostname is set, assume the server is exposed using default ports
return -1;
return hostnamePort;
}
return fromFrontChannel(originalUriInfo, URI::getPort, this::getPort, null);
return fromFrontChannel(originalUriInfo, URI::getPort, this::getPort, hostnamePort == -1 ? getPort(originalUriInfo) : hostnamePort);
}
@Override
public int getPort(UriInfo originalUriInfo) {
KeycloakSession session = Resteasy.getContextData(KeycloakSession.class);
int requestPort = session.getContext().getContextObject(HttpRequest.class).getUri().getBaseUri().getPort();
return noProxy ? defaultTlsPort : requestPort;
return noProxy && strictHttps ? defaultTlsPort : getRequestPort();
}
private <T> T forNonStrictBackChannel(UriInfo originalUriInfo, UrlType urlType,
@ -202,7 +209,7 @@ public final class DefaultHostnameProvider implements HostnameProvider, Hostname
hostnameEnabled = frontChannelHostName != null;
Boolean strictHttps = config.getBoolean("strict-https", false);
strictHttps = config.getBoolean("strict-https", false);
if (strictHttps) {
defaultHttpScheme = "https";
@ -211,14 +218,22 @@ public final class DefaultHostnameProvider implements HostnameProvider, Hostname
defaultPath = config.get("path");
noProxy = Configuration.getConfigValue("kc.proxy").getValue().equals("false");
defaultTlsPort = Integer.parseInt(Configuration.getConfigValue("kc.https-port").getValue());
hostnamePort = Integer.parseInt(Configuration.getConfigValue("kc.hostname-port").getValue());
adminHostName = config.get("admin");
strictBackChannel = config.getBoolean("strict-backchannel", false);
LOGGER.infov("Hostname settings: FrontEnd: {0}, Strict HTTPS: {1}, Path: {2}, Strict BackChannel: {3}, Admin: {4}",
LOGGER.infov("Hostname settings: FrontEnd: {0}, Strict HTTPS: {1}, Path: {2}, Strict BackChannel: {3}, Admin: {4}, Port: {5}, Proxied: {6}",
frontChannelHostName == null ? "<request>" : frontChannelHostName,
strictHttps,
defaultPath == null ? "<request>" : defaultPath,
strictBackChannel,
adminHostName == null ? "<request>" : adminHostName);
adminHostName == null ? "<request>" : adminHostName,
hostnamePort,
!noProxy);
}
private int getRequestPort() {
KeycloakSession session = Resteasy.getContextData(KeycloakSession.class);
return session.getContext().getContextObject(HttpRequest.class).getUri().getBaseUri().getPort();
}
}

View file

@ -113,8 +113,14 @@ public class CLITestExtension extends QuarkusMainTestExtension {
public void afterEach(ExtensionContext context) throws Exception {
DistributionTest distConfig = getDistributionConfig(context);
if (distConfig != null && distConfig.keepAlive()) {
dist.stop();
if (distConfig != null) {
if (distConfig.keepAlive()) {
dist.stop();
}
if (DistributionTest.ReInstall.BEFORE_TEST.equals(distConfig.reInstall())) {
dist = null;
}
}
super.afterEach(context);

View file

@ -265,7 +265,7 @@ public final class RawKeycloakDistribution implements KeycloakDistribution {
Path distPath = distRootPath.resolve(distDirName.substring(0, distDirName.lastIndexOf('.')));
if (!inited || (reCreate || !distPath.toFile().exists())) {
distPath.toFile().delete();
FileUtils.deleteDirectory(distPath.toFile());
ZipUtils.unzip(distFile.toPath(), distRootPath);
}
@ -319,8 +319,6 @@ public final class RawKeycloakDistribution implements KeycloakDistribution {
builder.environment().put("KEYCLOAK_ADMIN", "admin");
builder.environment().put("KEYCLOAK_ADMIN_PASSWORD", "admin");
FileUtils.deleteDirectory(distPath.resolve("data").toFile());
keycloak = builder.start();
}

View file

@ -0,0 +1,126 @@
/*
* Copyright 2021 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.it.cli.dist;
import static io.restassured.RestAssured.when;
import org.junit.Assert;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.keycloak.it.cli.dist.util.CopyTLSKeystore;
import org.keycloak.it.junit5.extension.BeforeStartDistribution;
import org.keycloak.it.junit5.extension.DistributionTest;
import org.keycloak.it.junit5.extension.RawDistOnly;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import io.quarkus.test.junit.main.Launch;
import io.restassured.RestAssured;
@DistributionTest(keepAlive = true, reInstall = DistributionTest.ReInstall.BEFORE_TEST)
@BeforeStartDistribution(CopyTLSKeystore.class)
@RawDistOnly(reason = "Containers are immutable")
public class HostnameDistTest {
@BeforeAll
public static void onBeforeAll() {
RestAssured.useRelaxedHTTPSValidation();
}
@Test
@Launch({ "start-dev", "--hostname=mykeycloak.127.0.0.1.nip.io" })
public void testSchemeAndPortFromRequestWhenNoProxySet() {
assertFrontEndUrl("http://mykeycloak.127.0.0.1.nip.io:8080", "http://mykeycloak.127.0.0.1.nip.io:8080/");
assertFrontEndUrl("http://localhost:8080", "http://mykeycloak.127.0.0.1.nip.io:8080/");
assertFrontEndUrl("https://localhost:8443", "https://mykeycloak.127.0.0.1.nip.io:8443/");
}
@Test
@Launch({ "start-dev", "--hostname=mykeycloak.127.0.0.1.nip.io", "--hostname-strict-https=true" })
public void testForceHttpsSchemeAndPortWhenStrictHttpsEnabled() {
assertFrontEndUrl("http://mykeycloak.127.0.0.1.nip.io:8080", "https://mykeycloak.127.0.0.1.nip.io:8443/");
assertFrontEndUrl("http://localhost:8080", "https://mykeycloak.127.0.0.1.nip.io:8443/");
}
@Test
@Launch({ "start-dev", "--hostname=mykeycloak.127.0.0.1.nip.io", "--hostname-port=8443" })
public void testForceHostnamePortWhenNoProxyIsSet() {
assertFrontEndUrl("http://mykeycloak.127.0.0.1.nip.io:8080", "http://mykeycloak.127.0.0.1.nip.io:8443/");
assertFrontEndUrl("https://mykeycloak.127.0.0.1.nip.io:8443", "https://mykeycloak.127.0.0.1.nip.io:8443/");
}
@Test
@Launch({ "start-dev", "--hostname=mykeycloak.127.0.0.1.nip.io", "--proxy=edge" })
public void testUseDefaultPortsWhenProxyIsSet() {
assertFrontEndUrl("http://mykeycloak.127.0.0.1.nip.io:8080", "http://mykeycloak.127.0.0.1.nip.io/");
assertFrontEndUrl("https://mykeycloak.127.0.0.1.nip.io:8443", "https://mykeycloak.127.0.0.1.nip.io/");
}
@Test
@Launch({ "start-dev", "--hostname=mykeycloak.127.0.0.1.nip.io", "--proxy=edge", "--hostname-strict-https=true" })
public void testUseDefaultPortsAndHttpsSchemeWhenProxyIsSetAndStrictHttpsEnabled() {
assertFrontEndUrl("http://mykeycloak.127.0.0.1.nip.io:8080", "https://mykeycloak.127.0.0.1.nip.io/");
}
@Test
@Launch({ "start-dev", "--hostname=mykeycloak.127.0.0.1.nip.io" })
public void testBackEndUrlFromRequest() {
assertBackEndUrl("http://localhost:8080", "http://localhost:8080/");
}
@Test
@Launch({ "start-dev", "--hostname=mykeycloak.127.0.0.1.nip.io", "--hostname-strict-backchannel=true" })
public void testBackEndUrlSameAsFrontEndUrl() {
assertBackEndUrl("http://localhost:8080", "http://mykeycloak.127.0.0.1.nip.io:8080/");
}
@Test
@Launch({ "start-dev", "--hostname=mykeycloak.127.0.0.1.nip.io", "--hostname-path=/auth", "--hostname-strict=true", "--hostname-strict-backchannel=true" })
public void testSetHostnamePath() {
assertFrontEndUrl("http://localhost:8080", "http://mykeycloak.127.0.0.1.nip.io:8080/auth/");
assertBackEndUrl("http://localhost:8080", "http://mykeycloak.127.0.0.1.nip.io:8080/auth/");
}
@Test
@Launch({ "start-dev", "--hostname=mykeycloak.127.0.0.1.nip.io", "--https-port=8543", "--hostname-strict-https=true" })
public void testDefaultTlsPortChangeWhenHttpPortSet() {
assertFrontEndUrl("http://mykeycloak.127.0.0.1.nip.io:8080", "https://mykeycloak.127.0.0.1.nip.io:8543/");
}
@Test
@Launch({ "start-dev", "--hostname=mykeycloak.127.0.0.1.nip.io", "--hostname-strict-https=true", "--hostname-port=8543" })
public void testWelcomePageAdminUrl() {
Assert.assertTrue(when().get("http://mykeycloak.127.0.0.1.nip.io:8080").asString().contains("http://mykeycloak.127.0.0.1.nip.io:8080/admin/"));
Assert.assertTrue(when().get("https://mykeycloak.127.0.0.1.nip.io:8443").asString().contains("https://mykeycloak.127.0.0.1.nip.io:8443/admin/"));
Assert.assertTrue(when().get("http://localhost:8080").asString().contains("http://localhost:8080/admin/"));
Assert.assertTrue(when().get("https://localhost:8443").asString().contains("https://localhost:8443/admin/"));
}
private OIDCConfigurationRepresentation getServerMetadata(String baseUrl) {
return when().get(baseUrl + "/realms/master/.well-known/openid-configuration").as(OIDCConfigurationRepresentation.class);
}
private void assertFrontEndUrl(String requestBaseUrl, String expectedBaseUrl) {
Assert.assertEquals(expectedBaseUrl + "realms/master/protocol/openid-connect/auth", getServerMetadata(requestBaseUrl)
.getAuthorizationEndpoint());
}
private void assertBackEndUrl(String requestBaseUrl, String expectedBaseUrl) {
Assert.assertEquals(expectedBaseUrl + "realms/master/protocol/openid-connect/token", getServerMetadata(requestBaseUrl)
.getTokenEndpoint());
}
}

View file

@ -0,0 +1,30 @@
/*
* Copyright 2021 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.it.cli.dist.util;
import java.nio.file.Path;
import java.util.function.Consumer;
import org.keycloak.it.utils.KeycloakDistribution;
public class CopyTLSKeystore implements Consumer<KeycloakDistribution> {
@Override
public void accept(KeycloakDistribution distribution) {
distribution.copyOrReplaceFileFromClasspath("/server.keystore", Path.of("conf", "server.keystore"));
}
}

View file

@ -42,10 +42,11 @@ Hostname:
--hostname <hostname>
Hostname for the Keycloak server.
--hostname-admin <url>
Overrides the hostname for the admin console and APIs.
--hostname-path <path>
This should be set if proxy uses a different context-path for Keycloak.
--hostname-port <port>
The port used by the proxy when exposing the hostname. Set this option if the
proxy uses a port other than the default HTTP and HTTPS ports. Default: -1.
--hostname-strict <true|false>
Disables dynamically resolving the hostname from request headers. Should
always be set to true in production, unless proxy verifies the Host header.
@ -93,7 +94,7 @@ HTTP/TLS:
Proxy:
--proxy <mode> The proxy address forwarding mode if the server is behind a reverse proxy.
Possible values are: none,edge,reencrypt,passthrough Default: none.
Possible values are: edge,reencrypt,passthrough Default: none.
Vault:

View file

@ -68,10 +68,11 @@ Hostname:
--hostname <hostname>
Hostname for the Keycloak server.
--hostname-admin <url>
Overrides the hostname for the admin console and APIs.
--hostname-path <path>
This should be set if proxy uses a different context-path for Keycloak.
--hostname-port <port>
The port used by the proxy when exposing the hostname. Set this option if the
proxy uses a port other than the default HTTP and HTTPS ports. Default: -1.
--hostname-strict <true|false>
Disables dynamically resolving the hostname from request headers. Should
always be set to true in production, unless proxy verifies the Host header.
@ -134,7 +135,7 @@ Metrics:
Proxy:
--proxy <mode> The proxy address forwarding mode if the server is behind a reverse proxy.
Possible values are: none,edge,reencrypt,passthrough Default: none.
Possible values are: edge,reencrypt,passthrough Default: none.
Vault:

View file

@ -45,10 +45,11 @@ Hostname:
--hostname <hostname>
Hostname for the Keycloak server.
--hostname-admin <url>
Overrides the hostname for the admin console and APIs.
--hostname-path <path>
This should be set if proxy uses a different context-path for Keycloak.
--hostname-port <port>
The port used by the proxy when exposing the hostname. Set this option if the
proxy uses a port other than the default HTTP and HTTPS ports. Default: -1.
--hostname-strict <true|false>
Disables dynamically resolving the hostname from request headers. Should
always be set to true in production, unless proxy verifies the Host header.
@ -96,7 +97,7 @@ HTTP/TLS:
Proxy:
--proxy <mode> The proxy address forwarding mode if the server is behind a reverse proxy.
Possible values are: none,edge,reencrypt,passthrough Default: none.
Possible values are: edge,reencrypt,passthrough Default: none.
Vault:

View file

@ -83,10 +83,6 @@ public abstract class AbstractHostnameTest extends AbstractKeycloakTest {
additionalArgs.add("--hostname-strict-https=true");
}
additionalArgs.add("--hostname-strict-backchannel="+ forceBackendUrlToFrontendUrl);
if (adminUrl != null) {
URI adminUri = URI.create(adminUrl);
additionalArgs.add("--hostname-admin=" + adminUri.getHost());
}
container.setAdditionalBuildArgs(additionalArgs);
controller.start(suiteContext.getAuthServerInfo().getQualifier());
} else {

View file

@ -40,13 +40,14 @@ import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer.QUARKUS;
import static org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer.REMOTE;
import static org.keycloak.testsuite.util.OAuthClient.AUTH_SERVER_ROOT;
import static org.keycloak.testsuite.util.ServerURLs.getAuthServerContextRoot;
import javax.ws.rs.core.UriBuilder;
@AuthServerContainerExclude({REMOTE})
@AuthServerContainerExclude({REMOTE, QUARKUS})
public class DefaultHostnameTest extends AbstractHostnameTest {
@ArquillianResource