KEYCLOAK-14548 Add support for cached gzip encoding of resources

This commit is contained in:
stianst 2020-08-26 20:06:03 +02:00 committed by Stian Thorgersen
parent e34ff6cd9c
commit 76f7fbb984
15 changed files with 376 additions and 23 deletions

View file

@ -68,6 +68,7 @@
<module name="javax.api"/>
<module name="javax.activation.api"/>
<module name="javax.json.api"/>
<module name="org.apache.commons.io"/>
<module name="org.apache.httpcomponents"/>
<module name="org.twitter4j"/>
<module name="javax.transaction.api"/>

View file

@ -0,0 +1,70 @@
package org.keycloak.encoding;
import org.apache.commons.io.IOUtils;
import org.jboss.logging.Logger;
import org.keycloak.models.KeycloakSession;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.util.zip.GZIPOutputStream;
public class GzipResourceEncodingProvider implements ResourceEncodingProvider {
private static final Logger logger = Logger.getLogger(ResourceEncodingProvider.class);
private KeycloakSession session;
private File cacheDir;
public GzipResourceEncodingProvider(KeycloakSession session, File cacheDir) {
this.session = session;
this.cacheDir = cacheDir;
}
public InputStream getEncodedStream(StreamSupplier producer, String... path) {
StringBuilder sb = new StringBuilder();
sb.append(cacheDir.getAbsolutePath());
for (String p : path) {
sb.append(File.separatorChar);
sb.append(p);
}
sb.append(".gz");
String filePath = sb.toString();
try {
File encodedFile = new File(filePath);
if (!encodedFile.getCanonicalPath().startsWith(cacheDir.getCanonicalPath())) {
return null;
}
if (!encodedFile.exists()) {
InputStream is = producer.getInputStream();
if (is != null) {
File parent = encodedFile.getParentFile();
if (!parent.isDirectory()) {
parent.mkdirs();
}
FileOutputStream fos = new FileOutputStream(encodedFile);
GZIPOutputStream gos = new GZIPOutputStream(fos);
IOUtils.copy(is, gos);
gos.close();
is.close();
} else {
encodedFile = null;
}
}
return encodedFile != null ? new FileInputStream(encodedFile) : null;
} catch (Exception e) {
logger.warn("Failed to encode resource", e);
return null;
}
}
public String getEncoding() {
return "gzip";
}
}

View file

@ -0,0 +1,76 @@
package org.keycloak.encoding;
import org.apache.commons.io.FileUtils;
import org.jboss.logging.Logger;
import org.keycloak.Config;
import org.keycloak.common.Version;
import org.keycloak.models.KeycloakSession;
import java.io.File;
import java.io.IOException;
import java.util.HashSet;
import java.util.Set;
public class GzipResourceEncodingProviderFactory implements ResourceEncodingProviderFactory {
private static final Logger logger = Logger.getLogger(GzipResourceEncodingProviderFactory.class);
private Set<String> excludedContentTypes = new HashSet<>();
private File cacheDir;
@Override
public ResourceEncodingProvider create(KeycloakSession session) {
if (cacheDir == null) {
cacheDir = initCacheDir();
}
return new GzipResourceEncodingProvider(session, cacheDir);
}
@Override
public void init(Config.Scope config) {
String e = config.get("excludedContentTypes", "image/png image/jpeg");
for (String s : e.split(" ")) {
excludedContentTypes.add(s);
}
}
@Override
public boolean encodeContentType(String contentType) {
return !excludedContentTypes.contains(contentType);
}
@Override
public String getId() {
return "gzip";
}
private synchronized File initCacheDir() {
if (cacheDir != null) {
return cacheDir;
}
File cacheRoot = new File(System.getProperty("java.io.tmpdir"), "kc-gzip-cache");
File cacheDir = new File(cacheRoot, Version.RESOURCES_VERSION);
if (cacheRoot.isDirectory()) {
for (File f : cacheRoot.listFiles()) {
if (!f.getName().equals(Version.RESOURCES_VERSION)) {
try {
FileUtils.deleteDirectory(f);
} catch (IOException e) {
logger.warn("Failed to delete old gzip cache directory", e);
}
}
}
}
if (!cacheDir.isDirectory() && !cacheDir.mkdirs()) {
logger.warn("Failed to create gzip cache directory");
return null;
}
return cacheDir;
}
}

View file

@ -0,0 +1,26 @@
package org.keycloak.encoding;
import org.keycloak.models.KeycloakSession;
public class ResourceEncodingHelper {
public static ResourceEncodingProvider getResourceEncodingProvider(KeycloakSession session, String contentType) {
String acceptEncoding = session.getContext().getRequestHeaders().getHeaderString("Accept-Encoding");
if (acceptEncoding != null) {
for (String e : acceptEncoding.split(",")) {
e = e.trim();
ResourceEncodingProviderFactory f = (ResourceEncodingProviderFactory) session.getKeycloakSessionFactory().getProviderFactory(ResourceEncodingProvider.class, e);
if (f != null && f.encodeContentType(contentType)) {
ResourceEncodingProvider provider = session.getProvider(ResourceEncodingProvider.class, e.trim());
if (provider != null) {
return provider;
}
} else {
return null;
}
}
}
return null;
}
}

View file

@ -0,0 +1,24 @@
package org.keycloak.encoding;
import org.keycloak.provider.Provider;
import java.io.IOException;
import java.io.InputStream;
public interface ResourceEncodingProvider extends Provider {
InputStream getEncodedStream(StreamSupplier producer, String... path);
String getEncoding();
@Override
default void close() {
}
interface StreamSupplier {
InputStream getInputStream() throws IOException;
}
}

View file

@ -0,0 +1,23 @@
package org.keycloak.encoding;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderFactory;
public interface ResourceEncodingProviderFactory extends ProviderFactory<ResourceEncodingProvider> {
boolean encodeContentType(String contentType);
@Override
default void init(Config.Scope config) {
}
@Override
default void postInit(KeycloakSessionFactory factory) {
}
@Override
default void close() {
}
}

View file

@ -0,0 +1,29 @@
package org.keycloak.encoding;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;
public class ResourceEncodingSpi implements Spi {
@Override
public boolean isInternal() {
return true;
}
@Override
public String getName() {
return "resource-encoding";
}
@Override
public Class<? extends Provider> getProviderClass() {
return ResourceEncodingProvider.class;
}
@Override
public Class<? extends ProviderFactory> getProviderFactoryClass() {
return ResourceEncodingProviderFactory.class;
}
}

View file

@ -19,6 +19,9 @@ package org.keycloak.services.resources;
import org.jboss.resteasy.spi.HttpRequest;
import org.keycloak.common.Version;
import org.keycloak.encoding.ResourceEncodingHelper;
import org.keycloak.encoding.ResourceEncodingProvider;
import org.keycloak.models.KeycloakSession;
import org.keycloak.services.util.CacheControlUtil;
import org.keycloak.utils.MediaType;
@ -40,6 +43,9 @@ import java.io.InputStream;
@Path("/js")
public class JsResource {
@Context
private KeycloakSession session;
@Context
private HttpRequest request;
@ -120,11 +126,24 @@ public class JsResource {
cacheControl = CacheControlUtil.noCache();
}
String contentType = "text/javascript";
Cors cors = Cors.add(request).allowAllOrigins();
InputStream inputStream = getClass().getClassLoader().getResourceAsStream(name);
ResourceEncodingProvider encodingProvider = ResourceEncodingHelper.getResourceEncodingProvider(session, contentType);
InputStream inputStream;
if (encodingProvider != null) {
inputStream = encodingProvider.getEncodedStream(() -> getClass().getClassLoader().getResourceAsStream(name), "js", name);
} else {
inputStream = getClass().getClassLoader().getResourceAsStream(name);
}
if (inputStream != null) {
return cors.builder(Response.ok(inputStream).type("text/javascript").cacheControl(cacheControl)).build();
Response.ResponseBuilder rb = Response.ok(inputStream).type(contentType).cacheControl(cacheControl);
if (encodingProvider != null) {
rb.encoding(encodingProvider.getEncoding());
}
return cors.builder(rb).build();
} else {
return cors.builder(Response.status(Response.Status.NOT_FOUND)).build();
}

View file

@ -18,6 +18,7 @@ package org.keycloak.services.resources;
import com.fasterxml.jackson.core.type.TypeReference;
import org.jboss.logging.Logger;
import org.jboss.resteasy.spi.ResteasyProviderFactory;
import org.keycloak.Config;
import org.keycloak.common.util.Resteasy;
import org.keycloak.config.ConfigProviderFactory;
@ -160,7 +161,6 @@ public class KeycloakApplication extends Application {
sessionFactory.publish(new PostMigrationEvent());
setupScheduledTasks(sessionFactory);
}
protected void shutdown() {

View file

@ -19,6 +19,8 @@ package org.keycloak.services.resources;
import org.jboss.logging.Logger;
import org.keycloak.common.Version;
import org.keycloak.common.util.MimeTypeUtil;
import org.keycloak.encoding.ResourceEncodingHelper;
import org.keycloak.encoding.ResourceEncodingProvider;
import org.keycloak.models.KeycloakSession;
import org.keycloak.services.ServicesLogger;
import org.keycloak.services.util.CacheControlUtil;
@ -29,6 +31,7 @@ import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.Response;
import java.io.File;
import java.io.InputStream;
/**
@ -39,8 +42,6 @@ import java.io.InputStream;
@Path("/resources")
public class ThemeResource {
protected static final Logger logger = Logger.getLogger(ThemeResource.class);
@Context
private KeycloakSession session;
@ -60,10 +61,23 @@ public class ThemeResource {
}
try {
String contentType = MimeTypeUtil.getContentType(path);
Theme theme = session.theme().getTheme(themeName, Theme.Type.valueOf(themType.toUpperCase()));
InputStream resource = theme.getResourceAsStream(path);
ResourceEncodingProvider encodingProvider = ResourceEncodingHelper.getResourceEncodingProvider(session, contentType);
InputStream resource;
if (encodingProvider != null) {
resource = encodingProvider.getEncodedStream(() -> theme.getResourceAsStream(path), themType, themeName, path.replace('/', File.separatorChar));
} else {
resource = theme.getResourceAsStream(path);
}
if (resource != null) {
return Response.ok(resource).type(MimeTypeUtil.getContentType(path)).cacheControl(CacheControlUtil.getDefaultCacheControl()).build();
Response.ResponseBuilder rb = Response.ok(resource).type(contentType).cacheControl(CacheControlUtil.getDefaultCacheControl());
if (encodingProvider != null) {
rb.encoding(encodingProvider.getEncoding());
}
return rb.build();
} else {
return Response.status(Response.Status.NOT_FOUND).build();
}

View file

@ -0,0 +1 @@
org.keycloak.encoding.GzipResourceEncodingProviderFactory

View file

@ -22,4 +22,5 @@ org.keycloak.services.clientregistration.policy.ClientRegistrationPolicySpi
org.keycloak.authentication.actiontoken.ActionTokenHandlerSpi
org.keycloak.services.x509.X509ClientCertificateLookupSpi
org.keycloak.protocol.oidc.ext.OIDCExtSPI
org.keycloak.protocol.saml.preprocessor.SamlAuthenticationPreprocessorSpi
org.keycloak.protocol.saml.preprocessor.SamlAuthenticationPreprocessorSpi
org.keycloak.encoding.ResourceEncodingSpi

View file

@ -39,6 +39,7 @@ import org.jboss.logging.Logger;
import org.jboss.resteasy.plugins.server.servlet.ResteasyContextParameters;
import org.jboss.resteasy.plugins.server.undertow.UndertowJaxrsServer;
import org.jboss.resteasy.spi.ResteasyDeployment;
import org.jboss.resteasy.spi.ResteasyProviderFactory;
import org.jboss.shrinkwrap.api.Archive;
import org.jboss.shrinkwrap.api.spec.WebArchive;
import org.jboss.shrinkwrap.descriptor.api.Descriptor;
@ -83,6 +84,9 @@ public class KeycloakOnUndertow implements DeployableContainer<KeycloakOnUnderto
// RESTEASY-2034
deployment.setProperty(ResteasyContextParameters.RESTEASY_DISABLE_HTML_SANITIZER, true);
// Prevent double gzip encoding of resources
deployment.getDisabledProviderClasses().add("org.jboss.resteasy.plugins.interceptors.encoding.GZIPEncodingInterceptor");
DeploymentInfo di = undertow.undertowDeployment(deployment);
di.setClassLoader(getClass().getClassLoader());
di.setContextPath("/auth");

View file

@ -1,15 +1,37 @@
package org.keycloak.testsuite.theme;
import org.apache.commons.io.IOUtils;
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.junit.Assert;
import org.junit.Test;
import org.keycloak.broker.provider.util.SimpleHttp;
import org.keycloak.common.Version;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer;
import org.keycloak.testsuite.utils.io.IOUtil;
import org.keycloak.theme.Theme;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.Locale;
import java.util.zip.GZIPInputStream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
@AuthServerContainerExclude(AuthServer.REMOTE)
public class ThemeResourceProviderTest extends AbstractTestRealmKeycloakTest {
@ -56,6 +78,48 @@ public class ThemeResourceProviderTest extends AbstractTestRealmKeycloakTest {
});
}
@Test
public void gzipEncoding() throws IOException {
final String resourcesVersion = testingClient.server().fetch(session -> Version.RESOURCES_VERSION, String.class);
assertEncoded(suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/resources/" + resourcesVersion + "/welcome/keycloak/css/welcome.css", "body {");
assertEncoded(suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/js/keycloak.js", "function(root, factory)");
testingClient.server().run(session -> {
assertTrue(Paths.get(System.getProperty("java.io.tmpdir"), "kc-gzip-cache", resourcesVersion, "welcome", "keycloak", "css", "welcome.css.gz").toFile().isFile());
assertTrue(Paths.get(System.getProperty("java.io.tmpdir"), "kc-gzip-cache", resourcesVersion, "js", "keycloak.js.gz").toFile().isFile());
});
}
private void assertEncoded(String url, String expectedContent) throws IOException {
try (CloseableHttpClient httpClient = HttpClientBuilder.create().disableContentCompression().build()) {
HttpGet get = new HttpGet(url);
CloseableHttpResponse response = httpClient.execute(get);
InputStream is = response.getEntity().getContent();
assertNull(response.getFirstHeader("Content-Encoding"));
String plain = IOUtils.toString(is, StandardCharsets.UTF_8);
response.close();
get = new HttpGet(url);
get.addHeader("Accept-Encoding", "gzip");
response = httpClient.execute(get);
is = response.getEntity().getContent();
assertEquals("gzip", response.getFirstHeader("Content-Encoding").getValue());
String gzip = IOUtils.toString(new GZIPInputStream(is), StandardCharsets.UTF_8);
response.close();
assertEquals(plain, gzip);
assertTrue(plain.contains(expectedContent));
}
}
/**
* See KEYCLOAK-12926
*/
@ -64,25 +128,25 @@ public class ThemeResourceProviderTest extends AbstractTestRealmKeycloakTest {
testingClient.server().run(session -> {
try {
Theme theme = session.theme().getTheme("base", Theme.Type.LOGIN);
Assert.assertEquals("Test en_US_variant", theme.getMessages("messages", new Locale("en", "US", "variant")).get("test.keycloak-12926"));
Assert.assertEquals("Test en_US", theme.getMessages("messages", new Locale("en", "US")).get("test.keycloak-12926"));
Assert.assertEquals("Test en", theme.getMessages("messages", Locale.ENGLISH).get("test.keycloak-12926"));
Assert.assertEquals("Test en_US", theme.getMessages("messages", new Locale("en", "US")).get("test.keycloak-12926"));
Assert.assertEquals("Test en", theme.getMessages("messages", Locale.ENGLISH).get("test.keycloak-12926"));
assertEquals("Test en_US_variant", theme.getMessages("messages", new Locale("en", "US", "variant")).get("test.keycloak-12926"));
assertEquals("Test en_US", theme.getMessages("messages", new Locale("en", "US")).get("test.keycloak-12926"));
assertEquals("Test en", theme.getMessages("messages", Locale.ENGLISH).get("test.keycloak-12926"));
assertEquals("Test en_US", theme.getMessages("messages", new Locale("en", "US")).get("test.keycloak-12926"));
assertEquals("Test en", theme.getMessages("messages", Locale.ENGLISH).get("test.keycloak-12926"));
Assert.assertEquals("only de_AT_variant", theme.getMessages("messages", new Locale("de", "AT", "variant")).get("test.keycloak-12926-resolving1"));
Assert.assertNull(theme.getMessages("messages", new Locale("de", "AT")).get("test.keycloak-12926-resolving1"));
assertEquals("only de_AT_variant", theme.getMessages("messages", new Locale("de", "AT", "variant")).get("test.keycloak-12926-resolving1"));
assertNull(theme.getMessages("messages", new Locale("de", "AT")).get("test.keycloak-12926-resolving1"));
Assert.assertEquals("only de_AT", theme.getMessages("messages", new Locale("de", "AT", "variant")).get("test.keycloak-12926-resolving2"));
Assert.assertNull(theme.getMessages("messages", new Locale("de")).get("test.keycloak-12926-resolving2"));
assertEquals("only de_AT", theme.getMessages("messages", new Locale("de", "AT", "variant")).get("test.keycloak-12926-resolving2"));
assertNull(theme.getMessages("messages", new Locale("de")).get("test.keycloak-12926-resolving2"));
Assert.assertEquals("only de", theme.getMessages("messages", new Locale("de", "AT", "variant")).get("test.keycloak-12926-only_de"));
Assert.assertNull(theme.getMessages("messages", Locale.ENGLISH).get("test.keycloak-12926-only_de"));
assertEquals("only de", theme.getMessages("messages", new Locale("de", "AT", "variant")).get("test.keycloak-12926-only_de"));
assertNull(theme.getMessages("messages", Locale.ENGLISH).get("test.keycloak-12926-only_de"));
Assert.assertEquals("fallback en", theme.getMessages("messages", new Locale("de", "AT", "variant")).get("test.keycloak-12926-resolving3"));
Assert.assertEquals("fallback en", theme.getMessages("messages", new Locale("de", "AT")).get("test.keycloak-12926-resolving3"));
Assert.assertEquals("fallback en", theme.getMessages("messages", new Locale("de")).get("test.keycloak-12926-resolving3"));
Assert.assertNull(theme.getMessages("messages", Locale.ENGLISH).get("fallback en"));
assertEquals("fallback en", theme.getMessages("messages", new Locale("de", "AT", "variant")).get("test.keycloak-12926-resolving3"));
assertEquals("fallback en", theme.getMessages("messages", new Locale("de", "AT")).get("test.keycloak-12926-resolving3"));
assertEquals("fallback en", theme.getMessages("messages", new Locale("de")).get("test.keycloak-12926-resolving3"));
assertNull(theme.getMessages("messages", Locale.ENGLISH).get("fallback en"));
} catch (IOException e) {
Assert.fail(e.getMessage());

View file

@ -374,6 +374,7 @@ public class KeycloakServer {
long start = System.currentTimeMillis();
ResteasyDeployment deployment = new ResteasyDeployment();
deployment.setApplicationClass(KeycloakApplication.class.getName());
Builder builder = Undertow.builder()