mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-09 23:12:06 -03:30
Redirect requests from outdated theme version to the current theme version
Closes #39723 Signed-off-by: Alexander Schwartz <aschwart@redhat.com>
This commit is contained in:
parent
ad92af3c58
commit
4af3d7cc9d
@ -123,11 +123,14 @@ It can be useful for instance if you redeployed custom providers or custom theme
|
||||
|
||||
Theme properties are set in the file `<THEME TYPE>/theme.properties` in the theme directory.
|
||||
|
||||
* parent - Parent theme to extend
|
||||
* import - Import resources from another theme
|
||||
* common - Override the common resource path. The default value is `common/keycloak` when not specified. This value would be used as value of suffix of `${url.resourcesCommonPath}`, which is used typically in freemarker templates (prefix of `${url.resoucesCommonPath}` value is theme root uri).
|
||||
* styles - Space-separated list of styles to include
|
||||
* locales - Comma-separated list of supported locales
|
||||
`parent`:: Parent theme to extend
|
||||
`import`:: Import resources from another theme
|
||||
`common`:: Override the common resource path. The default value is `common/keycloak` when not specified. This value would be used as value of suffix of `${url.resourcesCommonPath}`, which is used typically in freemarker templates (prefix of `${url.resoucesCommonPath}` value is theme root uri).
|
||||
`styles`:: Space-separated list of styles to include
|
||||
`locales`:: Comma-separated list of supported locales
|
||||
`contentHashPattern`:: Regex pattern of a file path in the theme where files have a content hash as part of their file name.
|
||||
A content hash is usually an abbreviated hash of the file's contents. The hash will change when the contents of the file have changed, and is usually created using the bundling process of the JavaScript application bundling.
|
||||
When the preview feature `rolling-updates:v2` is enabled, this allows for a more seamless rolling upgrade.
|
||||
|
||||
There are a list of properties that can be used to change the css class used for certain element types. For a list of these properties look at the theme.properties
|
||||
file in the corresponding type of the keycloak theme (`themes/keycloak/<THEME TYPE>/theme.properties`).
|
||||
|
||||
@ -113,5 +113,30 @@ The `message` field explains why this strategy was chosen.
|
||||
|
||||
|===
|
||||
|
||||
[[operator-rolling-updates-for-patch-releases]]
|
||||
== Rolling updates for patch releases
|
||||
|
||||
WARNING: This behavior is currently in an experimental mode, and it is not recommended for use in production.
|
||||
|
||||
It is possible to enable automatic rolling updates when upgrading to a newer patch version in the same `+major.minor+` release stream.
|
||||
|
||||
To enable this behavior, enable feature `rolling-updates:v2` as shown in the following example:
|
||||
|
||||
[source,yaml]
|
||||
----
|
||||
apiVersion: k8s.keycloak.org/v2alpha1
|
||||
kind: Keycloak
|
||||
metadata:
|
||||
name: example-kc
|
||||
spec:
|
||||
features:
|
||||
enabled:
|
||||
- rolling-updates:v2
|
||||
update:
|
||||
strategy: Auto
|
||||
----
|
||||
|
||||
Read more about rolling updates for patch releases in the <@links.server id="update-compatibility" anchor="rolling-updates-for-patch-releases" /> {section}.
|
||||
|
||||
</@tmpl.guide>
|
||||
|
||||
|
||||
@ -149,13 +149,27 @@ The feature `rolling-updates` is disabled.
|
||||
|
||||
WARNING: This behavior is currently in an experimental mode, and it is not recommended for use in production.
|
||||
|
||||
It is possible to configure the {project_name} compatibility command to allow rolling updates when updating from a version to a same patch version from the same `major.minor` release stream.
|
||||
It is possible to configure the {project_name} compatibility command to allow rolling updates when upgrading to a newer patch version in the same `+major.minor+` release stream.
|
||||
|
||||
To enable this behavior for compatibility check command enable feature `rolling-updates:v2` as shown in the following example.
|
||||
<@kc.updatecompatibility parameters="check --file=/path/to/file.json --features=rolling-updates:v2"/>
|
||||
|
||||
Note there is no change needed when generating metadata using `metadata` command.
|
||||
|
||||
Recommended Configuration:
|
||||
|
||||
* Enable sticky sessions in your loadbalancer to avoid users bouncing between different versions of {project_name} as this could result in users needing to refresh their Account Console and Admin UI multiple times while the upgrade is progressing.
|
||||
|
||||
Supported functionality during rolling updates:
|
||||
|
||||
* Users can log in and log out for OpenID Connect clients.
|
||||
|
||||
* OpenID Connect clients can perform all operations, for example, refreshing tokens and querying the user info endpoint.
|
||||
|
||||
Known limitations:
|
||||
|
||||
* If there have been changes to the Account Console or Admin UI in the patch release, and the user opened the Account Console or Admin UI before or during the upgrade, the user might see an error message and be asked to reload the application while navigating in browser during or after the upgrade.
|
||||
|
||||
== Further reading
|
||||
|
||||
The {project_name} Operator uses the functionality described above to determine if a rolling update is possible. See the <@links.operator id="rolling-updates" /> {section} and the `Auto` strategy for more information.
|
||||
|
||||
@ -3,3 +3,4 @@ deprecatedMode=false
|
||||
darkMode=true
|
||||
|
||||
kcDarkModeClass=pf-v5-theme-dark
|
||||
contentHashPattern=assets/.*
|
||||
@ -2,3 +2,4 @@ parent=base
|
||||
darkMode=true
|
||||
|
||||
kcDarkModeClass=pf-v5-theme-dark
|
||||
contentHashPattern=assets/.*
|
||||
@ -30,7 +30,8 @@ import java.util.Properties;
|
||||
*/
|
||||
public interface Theme {
|
||||
|
||||
public static final String ACCOUNT_RESOURCE_PROVIDER_KEY = "accountResourceProvider";
|
||||
String ACCOUNT_RESOURCE_PROVIDER_KEY = "accountResourceProvider";
|
||||
String CONTENT_HASH_PATTERN = "contentHashPattern";
|
||||
|
||||
enum Type { LOGIN, ACCOUNT, ADMIN, EMAIL, WELCOME, COMMON };
|
||||
|
||||
@ -83,4 +84,22 @@ public interface Theme {
|
||||
|
||||
Properties getProperties() throws IOException;
|
||||
|
||||
/**
|
||||
* Check if the given path contains a content hash.
|
||||
* If a resource is requested from this path, and it has a content hash, this guarantees that if the file
|
||||
* exists in two versions of the theme, it will contain the same contents.
|
||||
* With this guarantee, a different version of Keycloak can return the same contents even if a caller asks for
|
||||
* a different version of Keycloak.
|
||||
*
|
||||
* @param path path to check for a content hash
|
||||
*/
|
||||
default boolean hasContentHash(String path) throws IOException {
|
||||
Object contentHashPattern = getProperties().get(CONTENT_HASH_PATTERN);
|
||||
if (contentHashPattern != null) {
|
||||
return path.matches(contentHashPattern.toString());
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -143,7 +143,7 @@ public class DefaultSecurityHeadersProvider implements SecurityHeadersProvider {
|
||||
}
|
||||
int status = responseContext.getStatus();
|
||||
if (status == 201 || status == 204 ||
|
||||
status == 301 || status == 302 || status == 303 || status == 307 || status == 308 ||
|
||||
status == 301 || status == 302 || status == 303 || status == 304 || status == 307 || status == 308 ||
|
||||
status == 400 || status == 401 || status == 403 || status == 404) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -17,12 +17,17 @@
|
||||
package org.keycloak.services.resources;
|
||||
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.HeaderParam;
|
||||
import jakarta.ws.rs.OPTIONS;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.CacheControl;
|
||||
import jakarta.ws.rs.core.HttpHeaders;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.UriBuilder;
|
||||
import org.keycloak.common.Profile;
|
||||
import org.keycloak.common.Version;
|
||||
import org.keycloak.common.util.MimeTypeUtil;
|
||||
import org.keycloak.encoding.ResourceEncodingHelper;
|
||||
@ -46,6 +51,7 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
@ -76,15 +82,48 @@ public class ThemeResource {
|
||||
*/
|
||||
@GET
|
||||
@Path("/{version}/{themeType}/{themeName}/{path:.*}")
|
||||
public Response getResource(@PathParam("version") String version, @PathParam("themeType") String themeType, @PathParam("themeName") String themeName, @PathParam("path") String path) {
|
||||
public Response getResource(@PathParam("version") String version, @PathParam("themeType") String themeType, @PathParam("themeName") String themeName, @PathParam("path") String path, @HeaderParam(HttpHeaders.IF_NONE_MATCH) String etag) {
|
||||
final Optional<Theme.Type> type = getThemeType(themeType);
|
||||
if (!version.equals(Version.RESOURCES_VERSION) || type.isEmpty()) {
|
||||
|
||||
if (!version.equals(Version.RESOURCES_VERSION) && !Profile.isFeatureEnabled(Profile.Feature.ROLLING_UPDATES_V2)) {
|
||||
return Response.status(Response.Status.NOT_FOUND).build();
|
||||
}
|
||||
|
||||
if (type.isEmpty()) {
|
||||
return Response.status(Response.Status.NOT_FOUND).build();
|
||||
}
|
||||
|
||||
try {
|
||||
String contentType = MimeTypeUtil.getContentType(path);
|
||||
Theme theme = session.theme().getTheme(themeName, type.get());
|
||||
|
||||
boolean hasContentHash = theme.hasContentHash(path);
|
||||
|
||||
if (Profile.isFeatureEnabled(Profile.Feature.ROLLING_UPDATES_V2)) {
|
||||
if (!version.equals(Version.RESOURCES_VERSION) && !hasContentHash) {
|
||||
// If it is not the right version, and it does not have a content hash, redirect.
|
||||
// If it is not the right version, but it has a content hash, continue to see if it exists.
|
||||
|
||||
// The redirect will lead the browser to a resource that it then (when retrieved successfully) can cache again.
|
||||
// This assumes that it is better to try to some content even if it is outdated or too new, instead of returning a 404.
|
||||
// This should usually work for images, CSS or (simple) JavaScript referenced in the login theme that needs to be
|
||||
// loaded while the rolling restart is progressing.
|
||||
return Response.temporaryRedirect(
|
||||
UriBuilder.fromResource(ThemeResource.class)
|
||||
.path("/{version}/{themeType}/{themeName}/{path}")
|
||||
// The 'path' can contain slashes, so encoding of slashes is set to false
|
||||
.build(new Object[]{Version.RESOURCES_VERSION, themeType, themeName, path}, false)
|
||||
).build();
|
||||
}
|
||||
|
||||
if (hasContentHash && Objects.equals(etag, Version.RESOURCES_VERSION)) {
|
||||
// We delivered this resource earlier, and its etag matches the resource version, so it has not changed
|
||||
return Response.notModified()
|
||||
.header(HttpHeaders.ETAG, Version.RESOURCES_VERSION)
|
||||
.cacheControl(CacheControlUtil.getDefaultCacheControl()).build();
|
||||
}
|
||||
}
|
||||
|
||||
ResourceEncodingProvider encodingProvider = session.theme().isCacheEnabled() ? ResourceEncodingHelper.getResourceEncodingProvider(session, contentType) : null;
|
||||
|
||||
InputStream resource;
|
||||
@ -96,6 +135,13 @@ public class ThemeResource {
|
||||
|
||||
if (resource != null) {
|
||||
Response.ResponseBuilder rb = Response.ok(resource).type(contentType).cacheControl(CacheControlUtil.getDefaultCacheControl());
|
||||
|
||||
if (Profile.isFeatureEnabled(Profile.Feature.ROLLING_UPDATES_V2)){
|
||||
if (hasContentHash) {
|
||||
// All items with a content hash receive an etag, so we can then provide a not-modified response later
|
||||
rb.header(HttpHeaders.ETAG, Version.RESOURCES_VERSION);
|
||||
}
|
||||
}
|
||||
if (encodingProvider != null) {
|
||||
rb.encoding(encodingProvider.getEncoding());
|
||||
}
|
||||
|
||||
@ -162,17 +162,27 @@ public class DefaultThemeManager implements ThemeManager {
|
||||
|
||||
private static class ExtendingTheme implements Theme {
|
||||
|
||||
private List<Theme> themes;
|
||||
private Set<ThemeResourceProvider> themeResourceProviders;
|
||||
private final List<Theme> themes;
|
||||
private final Set<ThemeResourceProvider> themeResourceProviders;
|
||||
|
||||
private Properties properties;
|
||||
|
||||
private ConcurrentHashMap<String, ConcurrentHashMap<Locale, Map<Locale, Properties>>> messages =
|
||||
private final ConcurrentHashMap<String, ConcurrentHashMap<Locale, Map<Locale, Properties>>> messages =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
private Pattern compiledContentHashPattern;
|
||||
|
||||
public ExtendingTheme(List<Theme> themes, Set<ThemeResourceProvider> themeResourceProviders) {
|
||||
this.themes = themes;
|
||||
this.themeResourceProviders = themeResourceProviders;
|
||||
try {
|
||||
Object contentHashPattern = getProperties().get(CONTENT_HASH_PATTERN);
|
||||
if (contentHashPattern != null) {
|
||||
compiledContentHashPattern = Pattern.compile(contentHashPattern.toString());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -250,6 +260,11 @@ public class DefaultThemeManager implements ThemeManager {
|
||||
return LocaleUtil.enhancePropertiesWithRealmLocalizationTexts(realm, locale, messagesByLocale);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasContentHash(String path) throws IOException {
|
||||
return compiledContentHashPattern != null && compiledContentHashPattern.matcher(path).matches();
|
||||
}
|
||||
|
||||
private Map<Locale, Properties> getMessagesByLocale(String baseBundlename, Locale locale) throws IOException {
|
||||
if (messages.get(baseBundlename) == null || messages.get(baseBundlename).get(locale) == null) {
|
||||
Locale parent = getParent(locale);
|
||||
|
||||
@ -117,7 +117,7 @@ public class KeycloakTestingClient implements AutoCloseable {
|
||||
String featureString;
|
||||
if (Profile.getFeatureVersions(feature.getUnversionedKey()).size() > 1) {
|
||||
featureString = feature.getVersionedKey();
|
||||
Profile.Feature featureVersionHighestPriority = Profile.getFeatureVersions(feature.getKey()).iterator().next();
|
||||
Profile.Feature featureVersionHighestPriority = Profile.getFeatureVersions(feature.getUnversionedKey()).iterator().next();
|
||||
if (featureVersionHighestPriority.getType().equals(Profile.Feature.Type.DEFAULT)) {
|
||||
enableFeature(featureVersionHighestPriority);
|
||||
}
|
||||
|
||||
@ -5,12 +5,18 @@ import org.apache.http.client.methods.CloseableHttpResponse;
|
||||
import org.apache.http.client.methods.HttpGet;
|
||||
import org.apache.http.impl.client.CloseableHttpClient;
|
||||
import org.apache.http.impl.client.HttpClientBuilder;
|
||||
import org.apache.http.util.EntityUtils;
|
||||
import org.hamcrest.CoreMatchers;
|
||||
import org.hamcrest.MatcherAssert;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.keycloak.common.Profile;
|
||||
import org.keycloak.common.Version;
|
||||
import org.keycloak.platform.Platform;
|
||||
import org.keycloak.representations.idm.RealmRepresentation;
|
||||
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
|
||||
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
|
||||
import org.keycloak.testsuite.arquillian.annotation.EnableFeatures;
|
||||
import org.keycloak.theme.Theme;
|
||||
|
||||
import java.io.File;
|
||||
@ -19,6 +25,8 @@ import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.zip.GZIPInputStream;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
@ -134,6 +142,57 @@ public class ThemeResourceProviderTest extends AbstractTestRealmKeycloakTest {
|
||||
assertNotFound(suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/resources/" + resourcesVersion + "/invalid-theme-type/keycloak/css/welcome.css");
|
||||
}
|
||||
|
||||
@Test
|
||||
@EnableFeatures(@EnableFeature(Profile.Feature.ROLLING_UPDATES_V2))
|
||||
public void fetchStaticResourceShouldRedirectOnUnknownVersion() throws IOException {
|
||||
final String resourcesVersion = testingClient.server().fetch(session -> Version.RESOURCES_VERSION, String.class);
|
||||
assertFound(suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/resources/" + resourcesVersion + "/login/keycloak.v2/css/styles.css");
|
||||
assertRedirect(suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/resources/" + "unknown" + "/login/keycloak.v2/css/styles.css");
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
@EnableFeatures(@EnableFeature(Profile.Feature.ROLLING_UPDATES_V2))
|
||||
public void fetchResourceWithContentHashShouldReturnContentIfVersionIsUnknown() throws IOException {
|
||||
final String resourcesVersion = testingClient.server().fetch(session -> Version.RESOURCES_VERSION, String.class);
|
||||
|
||||
String resource = getResourceWithContentHash();
|
||||
|
||||
// The original resource should be accessible.
|
||||
assertNoRedirect(suiteContext.getAuthServerInfo().getContextRoot().toString() + resource);
|
||||
|
||||
// The unknown resource should be accessible without a redirect.
|
||||
assertNoRedirect(suiteContext.getAuthServerInfo().getContextRoot().toString() + resource.replaceAll(Pattern.quote(resourcesVersion), "unknown"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@EnableFeatures(@EnableFeature(Profile.Feature.ROLLING_UPDATES_V2))
|
||||
public void fetchResourceWithContentHashShouldHonorEtag() throws IOException {
|
||||
String resource = getResourceWithContentHash();
|
||||
|
||||
// The first fetch should return an etag
|
||||
String etag = fetchEtag(suiteContext.getAuthServerInfo().getContextRoot().toString() + resource);
|
||||
|
||||
// The second fetch with the etag should return not modified
|
||||
assertEtagHonored(suiteContext.getAuthServerInfo().getContextRoot().toString() + resource, etag);
|
||||
}
|
||||
|
||||
private String getResourceWithContentHash() throws IOException {
|
||||
try (CloseableHttpClient httpClient = HttpClientBuilder.create().build()) {
|
||||
HttpGet get = new HttpGet(suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/admin/" + TEST_REALM_NAME + "/console/");
|
||||
try (CloseableHttpResponse response = httpClient.execute(get)) {
|
||||
assertEquals(200, response.getStatusLine().getStatusCode());
|
||||
String body = EntityUtils.toString(response.getEntity());
|
||||
Matcher matcher = Pattern.compile("<link rel=\"stylesheet\" href=\"([^\"]*)\">").matcher(body);
|
||||
if (matcher.find()) {
|
||||
return matcher.group(1);
|
||||
} else {
|
||||
throw new AssertionError("unable to find resource in body");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void assertNotFound(String url) throws IOException {
|
||||
try (CloseableHttpClient httpClient = HttpClientBuilder.create().build()) {
|
||||
HttpGet get = new HttpGet(url);
|
||||
@ -143,6 +202,57 @@ public class ThemeResourceProviderTest extends AbstractTestRealmKeycloakTest {
|
||||
}
|
||||
}
|
||||
|
||||
private void assertFound(String url) throws IOException {
|
||||
try (CloseableHttpClient httpClient = HttpClientBuilder.create().build()) {
|
||||
HttpGet get = new HttpGet(url);
|
||||
CloseableHttpResponse response = httpClient.execute(get);
|
||||
|
||||
MatcherAssert.assertThat(response.getStatusLine().getStatusCode(), CoreMatchers.equalTo(200));
|
||||
}
|
||||
}
|
||||
|
||||
private String fetchEtag(String url) throws IOException {
|
||||
try (CloseableHttpClient httpClient = HttpClientBuilder.create().build()) {
|
||||
HttpGet get = new HttpGet(url);
|
||||
CloseableHttpResponse response = httpClient.execute(get);
|
||||
|
||||
MatcherAssert.assertThat(response.getStatusLine().getStatusCode(), CoreMatchers.equalTo(200));
|
||||
|
||||
return response.getFirstHeader("ETag").getValue();
|
||||
}
|
||||
}
|
||||
|
||||
private void assertRedirect(String url) throws IOException {
|
||||
try (CloseableHttpClient httpClient = HttpClientBuilder.create().disableRedirectHandling().build()) {
|
||||
HttpGet get = new HttpGet(url);
|
||||
CloseableHttpResponse response = httpClient.execute(get);
|
||||
|
||||
MatcherAssert.assertThat(response.getStatusLine().getStatusCode(), CoreMatchers.equalTo(307));
|
||||
|
||||
assertFound(url);
|
||||
}
|
||||
}
|
||||
|
||||
private void assertEtagHonored(String url, String etag) throws IOException {
|
||||
try (CloseableHttpClient httpClient = HttpClientBuilder.create().disableRedirectHandling().build()) {
|
||||
HttpGet get = new HttpGet(url);
|
||||
get.addHeader("If-None-Match", etag);
|
||||
|
||||
CloseableHttpResponse response = httpClient.execute(get);
|
||||
|
||||
MatcherAssert.assertThat(response.getStatusLine().getStatusCode(), CoreMatchers.equalTo(304));
|
||||
}
|
||||
}
|
||||
|
||||
private void assertNoRedirect(String url) throws IOException {
|
||||
try (CloseableHttpClient httpClient = HttpClientBuilder.create().disableRedirectHandling().build()) {
|
||||
HttpGet get = new HttpGet(url);
|
||||
CloseableHttpResponse response = httpClient.execute(get);
|
||||
|
||||
MatcherAssert.assertThat(response.getStatusLine().getStatusCode(), CoreMatchers.equalTo(200));
|
||||
}
|
||||
}
|
||||
|
||||
private void assertEncoded(String url, String expectedContent) throws IOException {
|
||||
try (CloseableHttpClient httpClient = HttpClientBuilder.create().disableContentCompression().build()) {
|
||||
HttpGet get = new HttpGet(url);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user