diff --git a/core/pom.xml b/core/pom.xml index acbd6aedbed731a2f1276834e3d3e374a22c10b0..875d11918615021be30d323b85de588b741340c1 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -6,7 +6,7 @@ org.eclipsefoundation quarkus-commons - 0.6.1-SNAPSHOT + 0.6.2-SNAPSHOT ../pom.xml @@ -35,7 +35,7 @@ io.quarkus - quarkus-elytron-security-oauth2 + quarkus-oidc io.quarkus @@ -57,6 +57,11 @@ rest-assured test + + io.quarkus + quarkus-junit5-mockito + test + com.google.auto.value diff --git a/core/src/main/java/org/eclipsefoundation/core/config/CSRFGeneratorProvider.java b/core/src/main/java/org/eclipsefoundation/core/config/CSRFGeneratorProvider.java new file mode 100644 index 0000000000000000000000000000000000000000..f74b1822cefd422a7fe503ae8aa92af45e994a8d --- /dev/null +++ b/core/src/main/java/org/eclipsefoundation/core/config/CSRFGeneratorProvider.java @@ -0,0 +1,28 @@ +package org.eclipsefoundation.core.config; + +import javax.enterprise.context.Dependent; +import javax.enterprise.inject.Produces; + +import org.eclipsefoundation.core.model.CSRFGenerator; +import org.eclipsefoundation.core.model.CSRFGenerator.DefaultCSRFGenerator; + +import io.quarkus.arc.DefaultBean; +import io.quarkus.arc.Unremovable; + +/** + * Allows for the implementation of other mechanisms to provide CSRF tokens while enabling a default mechanism which + * uses random values at runtime to create sufficiently hardened values. + * + * @author Martin Lowe + * + */ +@Dependent +@Unremovable +public class CSRFGeneratorProvider { + + @Produces + @DefaultBean + public CSRFGenerator generator() { + return new DefaultCSRFGenerator(); + } +} diff --git a/core/src/main/java/org/eclipsefoundation/core/helper/CSRFHelper.java b/core/src/main/java/org/eclipsefoundation/core/helper/CSRFHelper.java index ceab0cc49aa9d04ef2db38131447ec4c6dba05b9..5d0d986182e7cb8491b3c5c9c797632e744dbbd8 100644 --- a/core/src/main/java/org/eclipsefoundation/core/helper/CSRFHelper.java +++ b/core/src/main/java/org/eclipsefoundation/core/helper/CSRFHelper.java @@ -1,19 +1,20 @@ package org.eclipsefoundation.core.helper; +import java.nio.charset.StandardCharsets; import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; +import javax.enterprise.inject.Instance; +import javax.inject.Inject; import javax.inject.Singleton; +import javax.servlet.http.HttpServletRequest; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipsefoundation.core.exception.FinalUnauthorizedException; import org.eclipsefoundation.core.model.AdditionalUserData; +import org.eclipsefoundation.core.model.CSRFGenerator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import io.undertow.util.HexConverter; - /** * Helper class for interacting with CSRF tokens within the server. Generates secure CSRF tokens and compares them to * the copy that exists within the current session object. @@ -25,66 +26,84 @@ import io.undertow.util.HexConverter; public final class CSRFHelper { public static final Logger LOGGER = LoggerFactory.getLogger(CSRFHelper.class); - @ConfigProperty(name = "security.token.salt", defaultValue = "short-salt") - String salt; - @ConfigProperty(name = "security.csrf.enabled", defaultValue = "false") boolean csrfEnabled; + @ConfigProperty(name = "security.csrf.enabled.distributed-mode", defaultValue = "false") + Instance distributedMode; - // cryptographically secure random number generator - private SecureRandom rnd; + @Inject + CSRFGenerator generator; /** * Generate a new CSRF token that has been hardened to make it more difficult to predict. * * @return a cryptographically-secure CSRF token to use in a session. */ - public String getNewCSRFToken() { - // use a random value salted with a configured static value - byte[] bytes = rnd().generateSeed(24); - String secureRnd = new String(bytes); - // create a secure random secret to embed in the user session - String preHash = secureRnd + salt; + public String getNewCSRFToken(HttpServletRequest httpServletRequest) { + return generator.getCSRFToken(httpServletRequest); + } - // create new digest to hash the result - MessageDigest md; - try { - md = MessageDigest.getInstance("SHA-256"); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException("Could not find SHA-256 algorithm to encode CSRF token", e); + /** + * Checks the current method of CSRF storage (session vs distributed) and returns the stored CSRF value. + * + * @param aud the current local session data + * @param httpServletRequest the request context + * @return the CSRF token for the current request. + */ + public String getSessionCSRFToken(AdditionalUserData aud, HttpServletRequest httpServletRequest) { + if (distributedMode.get()) { + return generator.getCSRFToken(httpServletRequest); + } else { + return aud.getCsrf(); } - // hash the results using the message digest - byte[] array = md.digest(preHash.getBytes()); - // convert back to a hex string to act as a token - return HexConverter.convertToHexString(array); + } + + /** + * Legacy method for comapring CSRF values using the session data object. This has been upgraded to be + * {@link CSRFHelper#compareCSRF(String, String)} to better accommodate distributed mode options. + * + * @param aud the session data for current user + * @param passedToken the passed CSRF header data + * @throws FinalUnauthorizedException when CSRF token is missing in the user data, the passed header value, or does + * not match + */ + @Deprecated(forRemoval = true) + public void compareCSRF(AdditionalUserData aud, String passedToken) { + compareCSRF(aud.getCsrf(), passedToken); } /** * Compares the passed CSRF token to the token for the current user session. * - * @param aud session data for current user - * @param passedCSRF the passed CSRF header data + * @param expectedToken the stored CSRF reference value + * @param passedToken the passed CSRF header data * @throws FinalUnauthorizedException when CSRF token is missing in the user data, the passed header value, or does * not match */ - public void compareCSRF(AdditionalUserData aud, String passedCSRF) { + public void compareCSRF(String expectedToken, String passedToken) { if (csrfEnabled) { - LOGGER.debug("Comparing following tokens:\n{}\n{}", aud == null ? null : aud.getCsrf(), passedCSRF); - if (aud == null || aud.getCsrf() == null) { + LOGGER.debug("Comparing following tokens:\n{}\n{}", expectedToken == null ? null : expectedToken, + passedToken); + if (expectedToken == null) { throw new FinalUnauthorizedException( "CSRF token not generated for current request and is required, refusing request"); - } else if (passedCSRF == null) { + } else if (passedToken == null) { throw new FinalUnauthorizedException("No CSRF token passed for current request, refusing request"); - } else if (!passedCSRF.equals(aud.getCsrf())) { + } else if (!verifyConstantTime(expectedToken, passedToken)) { throw new FinalUnauthorizedException("CSRF tokens did not match, refusing request"); } } } - private SecureRandom rnd() { - if (this.rnd == null) { - this.rnd = new SecureRandom(Long.toString(System.currentTimeMillis()).getBytes()); + private static boolean verifyConstantTime(String expectedToken, String token) { + if (expectedToken == token) { + return true; + } + if (expectedToken == null || token == null) { + return false; } - return rnd; + return MessageDigest.isEqual(expectedToken.getBytes(StandardCharsets.US_ASCII), + token.getBytes(StandardCharsets.US_ASCII)); } + } diff --git a/core/src/main/java/org/eclipsefoundation/core/helper/DateTimeHelper.java b/core/src/main/java/org/eclipsefoundation/core/helper/DateTimeHelper.java index fc102a53e1d581de42e62ffbc1a5ff27e8d48c2d..53bd89eaeda1c02406666c672f240eaae9f27eb5 100644 --- a/core/src/main/java/org/eclipsefoundation/core/helper/DateTimeHelper.java +++ b/core/src/main/java/org/eclipsefoundation/core/helper/DateTimeHelper.java @@ -7,6 +7,7 @@ package org.eclipsefoundation.core.helper; import java.time.ZoneId; +import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; @@ -16,49 +17,75 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** - * Central implementation for handling date time conversion in the service. - * Class uses Java8 DateTime formatters, creating an internal format that - * represents RFC 3339 + * Central implementation for handling date time conversion in the service. Class uses Java8 DateTime formatters, + * creating an internal format that represents RFC 3339 * * @author Martin Lowe */ public class DateTimeHelper { - private static final Logger LOGGER = LoggerFactory.getLogger(DateTimeHelper.class); - public static final String RAW_RFC_3339_FORMAT = "uuuu-MM-dd'T'HH:mm:ssXXX"; - private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(RAW_RFC_3339_FORMAT); + private static final Logger LOGGER = LoggerFactory.getLogger(DateTimeHelper.class); + public static final String RAW_RFC_3339_FORMAT = "uuuu-MM-dd'T'HH:mm:ssXXX"; + private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(RAW_RFC_3339_FORMAT); - /** - * Converts RFC 3339 compliant date string to date object. If non compliant - * string is passed, issue is logged and null is returned. If negative UTC - * timezone (-00:00) is passed, UTC time zone is assumed. - * - * @param dateString an RFC 3339 date string. - * @return a date object representing time in date string, or null if not in RFC - * 3339 format. - */ - public static Date toRFC3339(String dateString) { - if (dateString.isBlank()) return null; - try { - return Date.from(ZonedDateTime.parse(dateString, formatter).toInstant()); - } catch (DateTimeParseException e) { - LOGGER.warn("Could not parse date from string '{}'", dateString, e); - return null; - } - } - - /** - * Converts passed date to RFC 3339 compliant date string. Time is adjusted to - * be in UTC time. - * - * @param date the date object to convert to RFC 3339 format. - * @return the RFC 3339 format date string. - */ - public static String toRFC3339(Date date) { - if (date == null) return null; - return formatter.format(date.toInstant().atZone(ZoneId.of("UTC"))); - } + /** + * Converts RFC 3339 compliant date string to date object. If non compliant string is passed, issue is logged and + * null is returned. If negative UTC timezone (-00:00) is passed, UTC time zone is assumed. + * + * @param dateString an RFC 3339 date string. + * @return a date object representing time in date string, or null if not in RFC 3339 format. + */ + public static Date toRFC3339(String dateString) { + if (dateString.isBlank()) + return null; + try { + return Date.from(ZonedDateTime.parse(dateString, formatter).toInstant()); + } catch (DateTimeParseException e) { + LOGGER.warn("Could not parse date from string '{}'", dateString, e); + return null; + } + } - // hide constructor - private DateTimeHelper() { - } + /** + * Converts passed date to RFC 3339 compliant date string. Time is adjusted to be in UTC time. + * + * @param date the date object to convert to RFC 3339 format. + * @return the RFC 3339 format date string. + */ + public static String toRFC3339(Date date) { + if (date == null) + return null; + return formatter.format(date.toInstant().atZone(ZoneId.of("UTC"))); + } + + /** + * Return the current instant as a datetime object zoned to UTC. + * + * @return the current localdatetime in UTC + */ + public static ZonedDateTime now() { + return ZonedDateTime.now(ZoneOffset.UTC); + } + + /** + * Gets the current epoch milli according to UTC + * + * @return the current epoch milli in UTC + */ + public static long getMillis() { + return now().toInstant().toEpochMilli(); + } + + /** + * Returns the epoch milli of the time passed with UTC assumed. + * + * @param time the time object to retrieve epoch millis from + * @return the epoch millis of the passed time object in UTC + */ + public static long getMillis(ZonedDateTime time) { + return time.toInstant().toEpochMilli(); + } + + // hide constructor + private DateTimeHelper() { + } } diff --git a/core/src/main/java/org/eclipsefoundation/core/model/CSRFGenerator.java b/core/src/main/java/org/eclipsefoundation/core/model/CSRFGenerator.java new file mode 100644 index 0000000000000000000000000000000000000000..e7d51bfbdc372b0a4fb1bb28a3744848c77b8999 --- /dev/null +++ b/core/src/main/java/org/eclipsefoundation/core/model/CSRFGenerator.java @@ -0,0 +1,63 @@ +package org.eclipsefoundation.core.model; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Optional; +import java.util.UUID; + +import javax.servlet.http.HttpServletRequest; + +import org.eclipse.microprofile.config.ConfigProvider; + +import io.undertow.util.HexConverter; + +/** + * Interface used to generate random hardened tokens for use in requests. + * + * @author Martin Lowe + * + */ +public interface CSRFGenerator { + + /** + * Generates a random secure CSRF token to be used in requests. This token should be cryptographically secure and + * random to ensure that it cannot be generated from an external source. + * + * @param requestContext current request context that can be used for fingerprinting the request if required. + * @return a random encoded string token + */ + public String getCSRFToken(HttpServletRequest httpServletRequest); + + /** + * Default implementation of the CSRF generator, will use secure randoms generated on the fly at runtime to generate + * random seed values as base of token. These values will be hashed and salted to provide extra hardening of the + * value to make it harder to predict. + * + * @author Martin Lowe + * + */ + public static class DefaultCSRFGenerator implements CSRFGenerator { + + @Override + public String getCSRFToken(HttpServletRequest httpServletRequest) { + Optional salt = ConfigProvider.getConfig().getOptionalValue("security.csrf.token.salt", + String.class); + // use a random value salted with a configured static value + String secureBase = UUID.randomUUID().toString(); + // create a secure random secret to embed in the user session + String preHash = secureBase + salt.orElseGet(() -> "short-salt"); + + // create new digest to hash the result + MessageDigest md; + try { + md = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("Could not find SHA-256 algorithm to encode CSRF token", e); + } + // hash the results using the message digest + byte[] array = md.digest(preHash.getBytes()); + // convert back to a hex string to act as a token + return HexConverter.convertToHexString(array); + } + } +} diff --git a/core/src/main/java/org/eclipsefoundation/core/request/CSRFSecurityFilter.java b/core/src/main/java/org/eclipsefoundation/core/request/CSRFSecurityFilter.java index f54b4c0528a6c069b9e167faa95cbe26d6991afc..2bd5f7cf9460da19f3504ef3973218385c52dafc 100644 --- a/core/src/main/java/org/eclipsefoundation/core/request/CSRFSecurityFilter.java +++ b/core/src/main/java/org/eclipsefoundation/core/request/CSRFSecurityFilter.java @@ -4,8 +4,10 @@ import java.io.IOException; import javax.enterprise.inject.Instance; import javax.inject.Inject; +import javax.servlet.http.HttpServletRequest; import javax.ws.rs.container.ContainerRequestContext; import javax.ws.rs.container.ContainerRequestFilter; +import javax.ws.rs.core.Context; import javax.ws.rs.ext.Provider; import org.eclipse.microprofile.config.inject.ConfigProperty; @@ -30,7 +32,11 @@ public class CSRFSecurityFilter implements ContainerRequestFilter { @ConfigProperty(name = "security.csrf.enabled", defaultValue = "false") Instance csrfEnabled; + @ConfigProperty(name = "security.csrf.enabled.distributed-mode", defaultValue = "false") + Instance distributedMode; + @Context + HttpServletRequest httpServletRequest; @Inject Instance csrf; @Inject @@ -47,9 +53,13 @@ public class CSRFSecurityFilter implements ContainerRequestFilter { String token = requestContext.getHeaderString(RequestHeaderNames.CSRF_TOKEN); if (token == null || "".equals(token.trim())) { throw new FinalUnauthorizedException("No CSRF token passed for mutation call, refusing connection"); + } else if (distributedMode.get()) { + // distributed mode should return the stored token if it exists + csrf.get().compareCSRF(csrf.get().getNewCSRFToken(httpServletRequest), token); } else { // run comparison. If error, exception will be thrown - csrf.get().compareCSRF(aud, token); + csrf.get().compareCSRF(aud.getCsrf(), token); + } } } diff --git a/core/src/main/java/org/eclipsefoundation/core/response/CSRFHeaderFilter.java b/core/src/main/java/org/eclipsefoundation/core/response/CSRFHeaderFilter.java index 95e413279ee242c8d901ac8832d1f733401096b0..3ba930f867ee0d57b16471f722e52b897e4b0f04 100644 --- a/core/src/main/java/org/eclipsefoundation/core/response/CSRFHeaderFilter.java +++ b/core/src/main/java/org/eclipsefoundation/core/response/CSRFHeaderFilter.java @@ -4,9 +4,11 @@ import java.io.IOException; import javax.enterprise.inject.Instance; import javax.inject.Inject; +import javax.servlet.http.HttpServletRequest; import javax.ws.rs.container.ContainerRequestContext; import javax.ws.rs.container.ContainerResponseContext; import javax.ws.rs.container.ContainerResponseFilter; +import javax.ws.rs.core.Context; import javax.ws.rs.ext.Provider; import org.eclipse.microprofile.config.inject.ConfigProperty; @@ -23,6 +25,11 @@ import org.eclipsefoundation.core.namespace.RequestHeaderNames; public class CSRFHeaderFilter implements ContainerResponseFilter { @ConfigProperty(name = "security.csrf.enabled", defaultValue = "false") Instance csrfEnabled; + @ConfigProperty(name = "security.csrf.enabled.distributed-mode", defaultValue = "false") + Instance distributedMode; + + @Context + HttpServletRequest httpServletRequest; @Inject CSRFHelper csrf; @@ -34,12 +41,14 @@ public class CSRFHeaderFilter implements ContainerResponseFilter { throws IOException { // only attach if CSRF is enabled for the current runtime if (csrfEnabled.get()) { - // generate a new token if none is yet present - if (aud.getCsrf() == null) { - aud.setCsrf(csrf.getNewCSRFToken()); + // generate the token + String token = csrf.getNewCSRFToken(httpServletRequest); + // store token in session if not distributed mode + if (aud.getCsrf() == null && !distributedMode.get()) { + aud.setCsrf(token); } // attach the current CSRF token as a header on the request - responseContext.getHeaders().add(RequestHeaderNames.CSRF_TOKEN, aud.getCsrf()); + responseContext.getHeaders().add(RequestHeaderNames.CSRF_TOKEN, token); } } } diff --git a/core/src/test/java/org/eclipsefoundation/core/authenticated/helper/CSRFHelperTest.java b/core/src/test/java/org/eclipsefoundation/core/authenticated/helper/CSRFHelperTest.java index 7cc023d79135d25443a4805b639e1038fe9cb42f..a184e5cf80901953f1802a9edc4c4e853ef460da 100644 --- a/core/src/test/java/org/eclipsefoundation/core/authenticated/helper/CSRFHelperTest.java +++ b/core/src/test/java/org/eclipsefoundation/core/authenticated/helper/CSRFHelperTest.java @@ -1,13 +1,15 @@ package org.eclipsefoundation.core.authenticated.helper; import javax.inject.Inject; +import javax.servlet.http.HttpServletRequest; import org.eclipsefoundation.core.exception.FinalUnauthorizedException; import org.eclipsefoundation.core.helper.CSRFHelper; -import org.eclipsefoundation.core.model.AdditionalUserData; import org.eclipsefoundation.core.test.AuthenticatedTestProfile; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import org.mockito.Mockito; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit.TestProfile; @@ -23,55 +25,38 @@ class CSRFHelperTest { @Inject CSRFHelper csrf; + + private static HttpServletRequest mockRequest; + @BeforeAll + public static void setup() { + CSRFHelperTest.mockRequest = Mockito.mock(HttpServletRequest.class); + } + @Test void compareCSRF_validToken() { // generate a token to use in test - String csrfToken = csrf.getNewCSRFToken(); - // create session object with given CSRF token - AdditionalUserData aud = new AdditionalUserData(); - aud.setCsrf(csrfToken); + String csrfToken = csrf.getNewCSRFToken(mockRequest); // this should not throw as the tokens match - Assertions.assertDoesNotThrow(() -> csrf.compareCSRF(aud, csrfToken)); + Assertions.assertDoesNotThrow(() -> csrf.compareCSRF(csrfToken, csrfToken)); } @Test void compareCSRF_invalidToken() { // generate a token to use in test - String csrfToken = csrf.getNewCSRFToken(); - // create session object with given CSRF token - AdditionalUserData aud = new AdditionalUserData(); - aud.setCsrf(csrfToken); + String csrfToken = csrf.getNewCSRFToken(mockRequest); // this should throw as the tokens are not the same - Assertions.assertThrows(FinalUnauthorizedException.class, () -> csrf.compareCSRF(aud, "some-other-value")); + Assertions.assertThrows(FinalUnauthorizedException.class, () -> csrf.compareCSRF(csrfToken, "some-other-value")); } @Test void compareCSRF_noSubmittedToken() { // generate a token to use in test - String csrfToken = csrf.getNewCSRFToken(); - // create session object with given CSRF token - AdditionalUserData aud = new AdditionalUserData(); - aud.setCsrf(csrfToken); - + String csrfToken = csrf.getNewCSRFToken(mockRequest); // this should throw as the tokens are not the same - Assertions.assertThrows(FinalUnauthorizedException.class, () -> csrf.compareCSRF(aud, null)); - - // reset token value as its cleared between requests - aud.setCsrf(csrfToken); - Assertions.assertThrows(FinalUnauthorizedException.class, () -> csrf.compareCSRF(aud, "")); - } - - @Test - void compareCSRF_noGeneratedToken() { - // simulates a session object with no CSRF data (no previous calls) - AdditionalUserData aud = new AdditionalUserData(); - - String sampleCSRF = csrf.getNewCSRFToken(); - Assertions.assertThrows(FinalUnauthorizedException.class, () -> csrf.compareCSRF(aud, null)); - Assertions.assertThrows(FinalUnauthorizedException.class, () -> csrf.compareCSRF(aud, "")); - Assertions.assertThrows(FinalUnauthorizedException.class, () -> csrf.compareCSRF(aud, sampleCSRF)); + Assertions.assertThrows(FinalUnauthorizedException.class, () -> csrf.compareCSRF(csrfToken, null)); + Assertions.assertThrows(FinalUnauthorizedException.class, () -> csrf.compareCSRF(csrfToken, "")); } } diff --git a/core/src/test/java/org/eclipsefoundation/core/test/TestResource.java b/core/src/test/java/org/eclipsefoundation/core/test/TestResource.java index 3a64b1a666de6177ae69fd58ab1629f8d1aaf896..1b96423c7a66775a4240be311d3cc9e5c2ddd48d 100644 --- a/core/src/test/java/org/eclipsefoundation/core/test/TestResource.java +++ b/core/src/test/java/org/eclipsefoundation/core/test/TestResource.java @@ -39,7 +39,7 @@ public class TestResource { @GET public Response get(@HeaderParam(value = RequestHeaderNames.CSRF_TOKEN) String passedCsrf) { // check CSRF manually as its a get request - csrf.compareCSRF(aud, passedCsrf); + csrf.compareCSRF(aud.getCsrf(), passedCsrf); return Response.ok().build(); } diff --git a/persistence/deployment/pom.xml b/persistence/deployment/pom.xml index 1705a1ce9f1161bf111c667c5d2d5a0adbcd21ca..08ea49ccd8fb8c0d50aeae1f415bd62974c19522 100644 --- a/persistence/deployment/pom.xml +++ b/persistence/deployment/pom.xml @@ -1,75 +1,74 @@ - - 4.0.0 - - org.eclipsefoundation - quarkus-persistence-parent - 0.6.1-SNAPSHOT - - quarkus-persistence-deployment - Persistence - Deployment - - - io.quarkus - quarkus-arc-deployment - - - io.quarkus - quarkus-core-deployment - - - org.eclipsefoundation - quarkus-persistence - ${project.version} - - - io.quarkus - quarkus-resteasy-deployment - - - io.quarkus - quarkus-resteasy-jackson-deployment - - - io.quarkus - quarkus-undertow-deployment - - - io.quarkus - quarkus-smallrye-health-deployment - - - io.quarkus - quarkus-elytron-security-oauth2-deployment - - - io.quarkus - quarkus-cache-deployment - - - io.quarkus - quarkus-hibernate-orm-deployment - - - io.quarkus - quarkus-jdbc-mariadb-deployment - - - - - - maven-compiler-plugin - - - - io.quarkus - quarkus-extension-processor - ${quarkus.version} - - - - - - - + + 4.0.0 + + org.eclipsefoundation + quarkus-persistence-parent + 0.6.2-SNAPSHOT + + quarkus-persistence-deployment + Persistence - Deployment + + + io.quarkus + quarkus-arc-deployment + + + io.quarkus + quarkus-core-deployment + + + org.eclipsefoundation + quarkus-persistence + ${project.version} + + + io.quarkus + quarkus-resteasy-deployment + + + io.quarkus + quarkus-resteasy-jackson-deployment + + + io.quarkus + quarkus-undertow-deployment + + + io.quarkus + quarkus-smallrye-health-deployment + + + io.quarkus + quarkus-oidc-deployment + + + io.quarkus + quarkus-cache-deployment + + + io.quarkus + quarkus-hibernate-orm-deployment + + + io.quarkus + quarkus-jdbc-mariadb-deployment + + + + + + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${quarkus.version} + + + + + + + \ No newline at end of file diff --git a/persistence/deployment/src/main/java/org/eclipsefoundation/persistence/deployment/QuarkusPersistenceProcessor.java b/persistence/deployment/src/main/java/org/eclipsefoundation/persistence/deployment/QuarkusPersistenceProcessor.java index 91a48c2651b2d46fe6f025980557649b5127ea53..85434c8a1ee2c5455f56370c9ac91bb5690f4f4e 100644 --- a/persistence/deployment/src/main/java/org/eclipsefoundation/persistence/deployment/QuarkusPersistenceProcessor.java +++ b/persistence/deployment/src/main/java/org/eclipsefoundation/persistence/deployment/QuarkusPersistenceProcessor.java @@ -3,12 +3,16 @@ package org.eclipsefoundation.persistence.deployment; import java.util.Set; import javax.enterprise.context.ApplicationScoped; +import javax.enterprise.context.Dependent; +import javax.inject.Singleton; +import org.eclipsefoundation.persistence.config.DistributedCSRFProvider; import org.eclipsefoundation.persistence.config.ParameterizedSQLStatementBuilderConfiguration; import org.eclipsefoundation.persistence.dao.impl.DefaultHibernateDao; import org.eclipsefoundation.persistence.dao.impl.PlaceholderPersistenceDao; import org.eclipsefoundation.persistence.dto.BareNode; import org.eclipsefoundation.persistence.dto.NodeBase; +import org.eclipsefoundation.persistence.dto.DistributedCSRFToken.DistributedCSRFTokenFilter; import org.eclipsefoundation.persistence.model.ParameterizedSQLStatementBuilder; import org.eclipsefoundation.persistence.response.ResultsHeaderCachedResponse; import org.eclipsefoundation.persistence.service.impl.DefaultFilterService; @@ -25,8 +29,7 @@ class QuarkusPersistenceProcessor { @BuildStep RuntimeInitializedClassBuildItem parameterizedSQLStatementBuilderConfiguration() { - return new RuntimeInitializedClassBuildItem( - ParameterizedSQLStatementBuilder.class.getCanonicalName()); + return new RuntimeInitializedClassBuildItem(ParameterizedSQLStatementBuilder.class.getCanonicalName()); } /** @@ -50,6 +53,13 @@ class QuarkusPersistenceProcessor { // register the Hibernate DAO beanProducer.produce( AdditionalBeanBuildItem.builder().setUnremovable().addBeanClass(DefaultHibernateDao.class).build()); + // distributed provider makes use of DAO, and can only be used if persistence units exist + beanProducer.produce( + AdditionalBeanBuildItem.builder().setUnremovable().addBeanClass(DistributedCSRFProvider.class) + .setDefaultScope(DotName.createSimple(Dependent.class.getName())).build()); + beanProducer.produce( + AdditionalBeanBuildItem.builder().setUnremovable().addBeanClass(DistributedCSRFTokenFilter.class) + .setDefaultScope(DotName.createSimple(Singleton.class.getName())).build()); } else { // register placeholder to unblock downstream dependencies. This can be removed // if a search extension is implemented diff --git a/persistence/pom.xml b/persistence/pom.xml index 567aba21d48f3322c4653092c29a7aad042bd2da..417fec69f8fad4393bad4624b9ad3511be77f0ea 100644 --- a/persistence/pom.xml +++ b/persistence/pom.xml @@ -4,13 +4,13 @@ 4.0.0 org.eclipsefoundation quarkus-persistence-parent - 0.6.1-SNAPSHOT + 0.6.2-SNAPSHOT pom Persistence - Parent org.eclipsefoundation quarkus-commons - 0.6.1-SNAPSHOT + 0.6.2-SNAPSHOT ../pom.xml diff --git a/persistence/runtime/pom.xml b/persistence/runtime/pom.xml index cc5e1af21e06017ee4e78800f5ec40254b31de9e..23c29385e21a7e152fe919ebaa0f439e4f7eb641 100644 --- a/persistence/runtime/pom.xml +++ b/persistence/runtime/pom.xml @@ -4,7 +4,7 @@ org.eclipsefoundation quarkus-persistence-parent - 0.6.1-SNAPSHOT + 0.6.2-SNAPSHOT quarkus-persistence Persistence - Runtime diff --git a/persistence/runtime/src/main/java/org/eclipsefoundation/persistence/config/DistributedCSRFProvider.java b/persistence/runtime/src/main/java/org/eclipsefoundation/persistence/config/DistributedCSRFProvider.java new file mode 100644 index 0000000000000000000000000000000000000000..91f9ad9932f7d4bc1a8f0356a0b9bb0ad3ebab9a --- /dev/null +++ b/persistence/runtime/src/main/java/org/eclipsefoundation/persistence/config/DistributedCSRFProvider.java @@ -0,0 +1,25 @@ +package org.eclipsefoundation.persistence.config; + +import javax.enterprise.context.ApplicationScoped; +import javax.enterprise.inject.Produces; + +import org.eclipsefoundation.core.model.CSRFGenerator; +import org.eclipsefoundation.persistence.dao.impl.DefaultHibernateDao; +import org.eclipsefoundation.persistence.dto.DistributedCSRFToken.DistributedCSRFTokenFilter; +import org.eclipsefoundation.persistence.model.DistributedCSRFGenerator; + +import io.quarkus.arc.Priority; +import io.quarkus.arc.properties.IfBuildProperty; +import io.quarkus.arc.properties.UnlessBuildProperty; + +public class DistributedCSRFProvider { + + @Produces + @Priority(5) + @ApplicationScoped + @IfBuildProperty(name = "security.csrf.enabled.distributed-mode", stringValue = "true") + @UnlessBuildProperty(name = "security.csrf.distributed-mode.default-provider", stringValue = "false", enableIfMissing = true) + public CSRFGenerator distributedGenerator(DefaultHibernateDao defaultDao, DistributedCSRFTokenFilter filter) { + return new DistributedCSRFGenerator(defaultDao, filter); + } +} diff --git a/persistence/runtime/src/main/java/org/eclipsefoundation/persistence/dto/DistributedCSRFToken.java b/persistence/runtime/src/main/java/org/eclipsefoundation/persistence/dto/DistributedCSRFToken.java new file mode 100644 index 0000000000000000000000000000000000000000..134888762cc51743839ad7e21dd6c08362739a9d --- /dev/null +++ b/persistence/runtime/src/main/java/org/eclipsefoundation/persistence/dto/DistributedCSRFToken.java @@ -0,0 +1,145 @@ +package org.eclipsefoundation.persistence.dto; + +import java.time.ZonedDateTime; + +import javax.inject.Inject; +import javax.inject.Singleton; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.ws.rs.core.MultivaluedMap; + +import org.eclipsefoundation.persistence.dto.filter.DtoFilter; +import org.eclipsefoundation.persistence.model.DistributedCSRFGenerator; +import org.eclipsefoundation.persistence.model.DtoTable; +import org.eclipsefoundation.persistence.model.ParameterizedSQLStatement; +import org.eclipsefoundation.persistence.model.ParameterizedSQLStatementBuilder; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +@Entity +@Table +public class DistributedCSRFToken extends BareNode { + public static final DtoTable TABLE = new DtoTable(DistributedCSRFToken.class, "dct"); + + @Id + private String token; + private String userAgent; + private String ipAddress; + private String user; + private ZonedDateTime timestamp; + + @Override + @JsonIgnore + public Object getId() { + return getToken(); + } + + /** + * @return the token + */ + public String getToken() { + return token; + } + + /** + * @param token the token to set + */ + public void setToken(String token) { + this.token = token; + } + + /** + * @return the userAgent + */ + public String getUserAgent() { + return userAgent; + } + + /** + * @param userAgent the userAgent to set + */ + public void setUserAgent(String userAgent) { + this.userAgent = userAgent; + } + + /** + * @return the ipAddress + */ + public String getIpAddress() { + return ipAddress; + } + + /** + * @param ipAddress the ipAddress to set + */ + public void setIpAddress(String ipAddress) { + this.ipAddress = ipAddress; + } + + /** + * @return the user + */ + public String getUser() { + return user; + } + + /** + * @param user the user to set + */ + public void setUser(String user) { + this.user = user; + } + + /** + * @return the timestamp + */ + public ZonedDateTime getTimestamp() { + return timestamp; + } + + /** + * @param timestamp the timestamp to set + */ + public void setTimestamp(ZonedDateTime timestamp) { + this.timestamp = timestamp; + } + + @Singleton + public static class DistributedCSRFTokenFilter implements DtoFilter { + @Inject + ParameterizedSQLStatementBuilder builder; + + @Override + public ParameterizedSQLStatement getFilters(MultivaluedMap params, boolean isRoot) { + ParameterizedSQLStatement stmt = builder.build(TABLE); + if (isRoot) { + // IP check + String ip = params.getFirst(DistributedCSRFGenerator.IP_PARAM); + if (ip != null) { + stmt.addClause(new ParameterizedSQLStatement.Clause(TABLE.getAlias() + ".ipAddress = ?", + new Object[] { ip })); + } + // UA check + String ua = params.getFirst(DistributedCSRFGenerator.USERAGENT_PARAM); + if (ua != null) { + stmt.addClause(new ParameterizedSQLStatement.Clause(TABLE.getAlias() + ".userAgent = ?", + new Object[] { ua })); + } + // user check + String user = params.getFirst(DistributedCSRFGenerator.USER_PARAM); + if (user != null) { + stmt.addClause(new ParameterizedSQLStatement.Clause(TABLE.getAlias() + ".user = ?", + new Object[] { user })); + } + } + + return stmt; + } + + @Override + public Class getType() { + return DistributedCSRFToken.class; + } + } +} diff --git a/persistence/runtime/src/main/java/org/eclipsefoundation/persistence/model/DistributedCSRFGenerator.java b/persistence/runtime/src/main/java/org/eclipsefoundation/persistence/model/DistributedCSRFGenerator.java new file mode 100644 index 0000000000000000000000000000000000000000..7124aa93a051569f51febf9c67dc761d9c9edbed --- /dev/null +++ b/persistence/runtime/src/main/java/org/eclipsefoundation/persistence/model/DistributedCSRFGenerator.java @@ -0,0 +1,112 @@ +package org.eclipsefoundation.persistence.model; + +import java.net.URI; +import java.security.Principal; +import java.util.Arrays; +import java.util.List; +import java.util.StringTokenizer; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.core.MultivaluedMap; + +import org.eclipsefoundation.core.helper.DateTimeHelper; +import org.eclipsefoundation.core.model.CSRFGenerator.DefaultCSRFGenerator; +import org.eclipsefoundation.core.model.FlatRequestWrapper; +import org.eclipsefoundation.persistence.dao.PersistenceDao; +import org.eclipsefoundation.persistence.dto.DistributedCSRFToken; +import org.eclipsefoundation.persistence.dto.DistributedCSRFToken.DistributedCSRFTokenFilter; +import org.jboss.resteasy.specimpl.MultivaluedMapImpl; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.undertow.httpcore.HttpHeaderNames; + +/** + * Using IP address and useragent, create a fingerprint for the current user that will be used to access a stored + * distributed CSRF token. If one does not yet exist, one will be generated using the default method, stored for future + * reference, and returned. + * + * @author Martin Lowe + * + */ +public class DistributedCSRFGenerator extends DefaultCSRFGenerator { + public static final Logger LOGGER = LoggerFactory.getLogger(DistributedCSRFGenerator.class); + + public static final String USER_PARAM = "user"; + public static final String USERAGENT_PARAM = "useragent"; + public static final String IP_PARAM = "ip"; + + private PersistenceDao manager; + private DistributedCSRFTokenFilter filter; + + public DistributedCSRFGenerator(PersistenceDao manager, DistributedCSRFTokenFilter filter) { + LOGGER.info("Distributed CSRF strategy is enabled."); + this.manager = manager; + this.filter = filter; + } + + @Override + public String getCSRFToken(HttpServletRequest httpServletRequest) { + // generate a non-root query + MultivaluedMap params = getQueryParameters(httpServletRequest); + RDBMSQuery q = new RDBMSQuery<>( + new FlatRequestWrapper(URI.create(httpServletRequest.getRequestURL().toString())), filter, params); + q.setRoot(false); + // attempt to read existing tokens + List tokens = manager.get(q); + if (tokens.isEmpty()) { + // generate a new token to be stored in the distributed persistence table + String token = super.getCSRFToken(httpServletRequest); + DistributedCSRFToken t = new DistributedCSRFToken(); + t.setIpAddress(params.getFirst(IP_PARAM)); + t.setToken(token); + t.setUser(params.getFirst(USER_PARAM)); + t.setUserAgent(params.getFirst(USERAGENT_PARAM)); + t.setTimestamp(DateTimeHelper.now()); + List createdTokens = manager.add(q, Arrays.asList(t)); + if (!createdTokens.isEmpty()) { + LOGGER.trace("Generated distributed CSRF token for current fingerprint; IP: {}, UA: {}, token: {}", + t.getIpAddress(), t.getUserAgent(), token); + return token; + } + } else { + DistributedCSRFToken t = tokens.get(0); + LOGGER.trace("Found existing distributed CSRF token for current fingerprint; IP: {}, UA: {}, token: {}", + t.getIpAddress(), t.getUserAgent(), t.getToken()); + return t.getToken(); + } + LOGGER.info("Error while generating/retrieving CSRF entry for current user; IP: {}, UA: {}", + params.getFirst(IP_PARAM), params.getFirst(USERAGENT_PARAM)); + return null; + } + + public void destroyCurrentToken(HttpServletRequest httpServletRequest) { + MultivaluedMap params = getQueryParameters(httpServletRequest); + LOGGER.error("Destroying retrieving CSRF entry for current user; IP: {}, UA: {}", params.getFirst(IP_PARAM), + params.getFirst(USERAGENT_PARAM)); + manager.delete(new RDBMSQuery<>( + new FlatRequestWrapper(URI.create(httpServletRequest.getRequestURL().toString())), filter, params)); + } + + private MultivaluedMap getQueryParameters(HttpServletRequest httpServletRequest) { + // get the markers used to identify a user (outside of a unique session ID) + String ipAddr = getClientIpAddress(httpServletRequest); + String userAgent = httpServletRequest.getHeader(HttpHeaderNames.USER_AGENT); + Principal user = httpServletRequest.getUserPrincipal(); + MultivaluedMap params = new MultivaluedMapImpl<>(); + params.add(IP_PARAM, ipAddr); + params.add(USERAGENT_PARAM, userAgent); + params.add(USER_PARAM, user != null ? user.getName() : null); + return params; + } + + private String getClientIpAddress(HttpServletRequest request) { + String forwardedFor = request.getHeader(HttpHeaderNames.X_FORWARDED_FOR); + if (forwardedFor == null) { + return request.getRemoteAddr(); + } else { + // get the first most address in forwarded chain + return new StringTokenizer(forwardedFor, ",").nextToken().trim(); + } + } +} diff --git a/pom.xml b/pom.xml index 7ded90c8d997292a3b898b8a58688104d519465e..9a57239b8ff4d66d64e03021be13111c7065b7ae 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ org.eclipsefoundation quarkus-commons Java SDK Commons - 0.6.1-SNAPSHOT + 0.6.2-SNAPSHOT pom 3.8.1 diff --git a/search/pom.xml b/search/pom.xml index c20670b64b0ee76566820c11a620a95b2841c355..07f0a2b64a519eba2af4a79bd00ba8e94ceb45d4 100644 --- a/search/pom.xml +++ b/search/pom.xml @@ -6,7 +6,7 @@ org.eclipsefoundation quarkus-commons - 0.6.1-SNAPSHOT + 0.6.2-SNAPSHOT ../pom.xml