diff --git a/components/camel-platform-http-vertx/src/main/java/org/apache/camel/component/platform/http/vertx/VertxPlatformHttpServer.java b/components/camel-platform-http-vertx/src/main/java/org/apache/camel/component/platform/http/vertx/VertxPlatformHttpServer.java index c81e3e8d5b01d..132c3c6139ac8 100644 --- a/components/camel-platform-http-vertx/src/main/java/org/apache/camel/component/platform/http/vertx/VertxPlatformHttpServer.java +++ b/components/camel-platform-http-vertx/src/main/java/org/apache/camel/component/platform/http/vertx/VertxPlatformHttpServer.java @@ -183,6 +183,11 @@ protected void initializeServer() { subRouter.route().handler(createCorsHandler(configuration)); } + if (configuration.getSessionConfig().isEnabled()) { + subRouter.route().handler( + configuration.getSessionConfig().createSessionHandler(vertx)); + } + router.route(configuration.getPath() + "*").subRouter(subRouter); context.getRegistry().bind( diff --git a/components/camel-platform-http-vertx/src/main/java/org/apache/camel/component/platform/http/vertx/VertxPlatformHttpServerConfiguration.java b/components/camel-platform-http-vertx/src/main/java/org/apache/camel/component/platform/http/vertx/VertxPlatformHttpServerConfiguration.java index 3720b250b59ce..b2bcc078e4e01 100644 --- a/components/camel-platform-http-vertx/src/main/java/org/apache/camel/component/platform/http/vertx/VertxPlatformHttpServerConfiguration.java +++ b/components/camel-platform-http-vertx/src/main/java/org/apache/camel/component/platform/http/vertx/VertxPlatformHttpServerConfiguration.java @@ -19,6 +19,12 @@ import java.time.Duration; import java.util.List; +import io.vertx.core.Vertx; +import io.vertx.core.http.CookieSameSite; +import io.vertx.ext.web.handler.SessionHandler; +import io.vertx.ext.web.sstore.ClusteredSessionStore; +import io.vertx.ext.web.sstore.LocalSessionStore; +import io.vertx.ext.web.sstore.SessionStore; import org.apache.camel.support.jsse.SSLContextParameters; /** @@ -39,6 +45,7 @@ public class VertxPlatformHttpServerConfiguration { private BodyHandler bodyHandler = new BodyHandler(); private Cors cors = new Cors(); + private SessionConfig sessionConfig = new SessionConfig(); public int getPort() { return getBindPort(); @@ -112,6 +119,14 @@ public void setCors(Cors corsConfiguration) { this.cors = corsConfiguration; } + public SessionConfig getSessionConfig() { + return sessionConfig; + } + + public void setSessionConfig(SessionConfig sessionConfig) { + this.sessionConfig = sessionConfig; + } + public BodyHandler getBodyHandler() { return bodyHandler; } @@ -120,6 +135,128 @@ public void setBodyHandler(BodyHandler bodyHandler) { this.bodyHandler = bodyHandler; } + public static class SessionConfig { + private boolean enabled; + private SessionStoreType storeType = SessionStoreType.LOCAL; + private String sessionCookieName = SessionHandler.DEFAULT_SESSION_COOKIE_NAME; + private String sessionCookiePath = SessionHandler.DEFAULT_SESSION_COOKIE_PATH; + private long sessionTimeOut = SessionHandler.DEFAULT_SESSION_TIMEOUT; + private boolean cookieSecure = SessionHandler.DEFAULT_COOKIE_SECURE_FLAG; + private boolean cookieHttpOnly = SessionHandler.DEFAULT_COOKIE_HTTP_ONLY_FLAG; + private int sessionIdMinLength = SessionHandler.DEFAULT_SESSIONID_MIN_LENGTH; + private CookieSameSite cookieSameSite = CookieSameSite.STRICT; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public SessionStoreType getStoreType() { + return this.storeType; + } + + public void setStoreType(SessionStoreType storeType) { + this.storeType = storeType; + } + + public String getSessionCookieName() { + return this.sessionCookieName; + } + + public void setSessionCookieName(String sessionCookieName) { + this.sessionCookieName = sessionCookieName; + } + + public String getSessionCookiePath() { + return this.sessionCookiePath; + } + + public void setSessionCookiePath(String sessionCookiePath) { + this.sessionCookiePath = sessionCookiePath; + } + + public long getSessionTimeOut() { + return this.sessionTimeOut; + } + + public void setSessionTimeout(long timeout) { + this.sessionTimeOut = timeout; + } + + public boolean isCookieSecure() { + return this.cookieSecure; + } + + // Instructs browsers to only send the cookie over HTTPS when set. + public void setCookieSecure(boolean cookieSecure) { + this.cookieSecure = cookieSecure; + } + + public boolean isCookieHttpOnly() { + return this.cookieHttpOnly; + } + + // Instructs browsers to prevent Javascript access to the cookie. + // Defends against XSS attacks. + public void setCookieHttpOnly(boolean cookieHttpOnly) { + this.cookieHttpOnly = cookieHttpOnly; + } + + public int getSessionIdMinLength() { + return this.sessionIdMinLength; + } + + public void setSessionIdMinLength(int sessionIdMinLength) { + this.sessionIdMinLength = sessionIdMinLength; + } + + public CookieSameSite getCookieSameSite() { + return this.cookieSameSite; + } + + public void setCookieSameSite(CookieSameSite cookieSameSite) { + this.cookieSameSite = cookieSameSite; + } + + public SessionHandler createSessionHandler(Vertx vertx) { + SessionStore sessionStore = storeType.create(vertx); + SessionHandler handler = SessionHandler.create(sessionStore); + configure(handler); + return handler; + } + + private void configure(SessionHandler handler) { + handler.setSessionTimeout(this.sessionTimeOut) + .setSessionCookieName(this.sessionCookieName) + .setSessionCookiePath(this.sessionCookiePath) + .setSessionTimeout(this.sessionTimeOut) + .setCookieHttpOnlyFlag(this.cookieHttpOnly) + .setCookieSecureFlag(this.cookieSecure) + .setMinLength(this.sessionIdMinLength) + .setCookieSameSite(this.cookieSameSite); + } + } + + public enum SessionStoreType { + LOCAL { + @Override + public SessionStore create(Vertx vertx) { + return LocalSessionStore.create(vertx); + } + }, + CLUSTERED { + @Override + public SessionStore create(Vertx vertx) { + return ClusteredSessionStore.create(vertx); + } + }; + + public abstract SessionStore create(Vertx vertx); + } + public static class Cors { private boolean enabled; private List origins; diff --git a/components/camel-platform-http-vertx/src/test/java/org/apache/camel/component/platform/http/vertx/VertxPlatformHttpSessionTest.java b/components/camel-platform-http-vertx/src/test/java/org/apache/camel/component/platform/http/vertx/VertxPlatformHttpSessionTest.java new file mode 100644 index 0000000000000..7ef4d3d8862cb --- /dev/null +++ b/components/camel-platform-http-vertx/src/test/java/org/apache/camel/component/platform/http/vertx/VertxPlatformHttpSessionTest.java @@ -0,0 +1,256 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.apache.camel.component.platform.http.vertx; + +import java.util.Arrays; + +import io.restassured.RestAssured; +import io.vertx.core.Handler; +import io.vertx.core.http.CookieSameSite; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.Session; +import io.vertx.ext.web.handler.SessionHandler; +import org.apache.camel.CamelContext; +import org.apache.camel.Exchange; +import org.apache.camel.ProducerTemplate; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.component.platform.http.PlatformHttpComponent; +import org.apache.camel.component.platform.http.PlatformHttpConstants; +import org.apache.camel.http.base.cookie.InstanceCookieHandler; +import org.apache.camel.impl.DefaultCamelContext; +import org.apache.camel.test.AvailablePortFinder; +import org.junit.jupiter.api.Test; + +import static io.restassured.RestAssured.given; +import static io.restassured.matcher.RestAssuredMatchers.detailedCookie; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.jupiter.api.Assertions.*; + +public class VertxPlatformHttpSessionTest { + + @Test + public void testSessionDisabled() throws Exception { + CamelContext context = createCamelContext(sessionConfig -> { + // session handling disabled by default + }); + + context.addRoutes(new RouteBuilder() { + @Override + public void configure() { + from("platform-http:/disabled") + .setBody().constant("disabled"); + } + }); + + try { + context.start(); + + given() + .when() + .get("/disabled") + .then() + .statusCode(200) + .header("set-cookie", nullValue()) + .header("cookie", nullValue()) + .body(equalTo("disabled")); + } finally { + context.stop(); + } + } + + @Test + public void testCookeForDefaultSessionConfig() throws Exception { + CamelContext context = createCamelContext(sessionConfig -> { + sessionConfig.setEnabled(true); + }); + + context.addRoutes(new RouteBuilder() { + @Override + public void configure() { + from("platform-http:/session") + .setBody().constant("session"); + } + }); + + try { + context.start(); + + String sessionCookieValue = given() + .when() + .get("/session") + .then() + .statusCode(200) + .cookie("vertx-web.session", + detailedCookie() + .path("/").value(notNullValue()) + .httpOnly(false) + .secured(false) + .sameSite("Strict")) + .header("cookie", nullValue()) + .body(equalTo("session")) + .extract().cookie("vertx-web.session"); + + assertTrue(sessionCookieValue.length() >= SessionHandler.DEFAULT_SESSIONID_MIN_LENGTH); + + } finally { + context.stop(); + } + } + + @Test + public void testCookieForModifiedSessionConfig() throws Exception { + CamelContext context = createCamelContext(sessionConfig -> { + sessionConfig.setSessionCookieName("vertx-session"); + sessionConfig.setEnabled(true); + sessionConfig.setSessionCookiePath("/session"); + sessionConfig.setCookieSecure(true); + sessionConfig.setCookieHttpOnly(true); + sessionConfig.setCookieSameSite(CookieSameSite.LAX); + sessionConfig.setSessionIdMinLength(64); + }); + + context.addRoutes(new RouteBuilder() { + @Override + public void configure() { + from("platform-http:/session") + .setBody().constant("session"); + } + }); + + try { + context.start(); + + String sessionCookieValue = given() + .when() + .get("/session") + .then() + .statusCode(200) + .cookie("vertx-session", + detailedCookie() + .path("/session").value(notNullValue()) + .httpOnly(true) + .secured(true) + .sameSite("Lax")) + .header("cookie", nullValue()) + .body(equalTo("session")) + .extract().cookie("vertx-session"); + + assertTrue(sessionCookieValue.length() >= 64); + + } finally { + context.stop(); + } + } + + @Test + public void testSessionHandling() throws Exception { + int port = AvailablePortFinder.getNextAvailable(); + CamelContext context = createCamelContext(port, + sessionConfig -> { + sessionConfig.setEnabled(true); + }); + addPlatformHttpEngineHandler(context, new HitCountHandler()); + context.getRegistry().bind("instanceCookieHander", new InstanceCookieHandler()); + + try { + context.addRoutes(new RouteBuilder() { + @Override + public void configure() { + from("platform-http:/session") + .setBody().constant("session"); + + from("direct:session") + .toF("http://localhost:%d/session?cookieHandler=#instanceCookieHander", + port); + } + }); + + context.start(); + + // initial call establishes session + ProducerTemplate template = context.createProducerTemplate(); + Exchange exchange = template.request("direct:session", null); + // 'set-cookie' header for new session, e.g. 'vertx-web.session=735944d69685aaf63421fb5b3c116b84; Path=/; SameSite=Strict' + String sessionCookie = getHeader("set-cookie", exchange); + assertNotNull(getHeader("set-cookie", exchange)); + assertEquals(getHeader("hitcount", exchange), "1"); + + // subsequent call reuses session + exchange = template.request("direct:session", null); + // 'cookie' header for existing session, e.g. 'vertx-web.session=735944d69685aaf63421fb5b3c116b84' + String cookieHeader = getHeader("cookie", exchange); + assertEquals(cookieHeader, sessionCookie.substring(0, sessionCookie.indexOf(';'))); + assertNull(getHeader("set-cookie", exchange)); + assertEquals(getHeader("hitcount", exchange), "2"); + + } finally { + context.stop(); + } + } + + private String getHeader(String header, Exchange exchange) { + return (String) exchange.getMessage().getHeader(header); + } + + private CamelContext createCamelContext(SessionConfigCustomizer customizer) + throws Exception { + int bindPort = AvailablePortFinder.getNextAvailable(); + RestAssured.port = bindPort; + return createCamelContext(bindPort, customizer); + } + + private CamelContext createCamelContext(int bindPort, SessionConfigCustomizer customizer) + throws Exception { + VertxPlatformHttpServerConfiguration conf = new VertxPlatformHttpServerConfiguration(); + conf.setBindPort(bindPort); + + VertxPlatformHttpServerConfiguration.SessionConfig sessionConfig + = new VertxPlatformHttpServerConfiguration.SessionConfig(); + customizer.customize(sessionConfig); + conf.setSessionConfig(sessionConfig); + + CamelContext camelContext = new DefaultCamelContext(); + camelContext.addService(new VertxPlatformHttpServer(conf)); + return camelContext; + } + + private void addPlatformHttpEngineHandler(CamelContext camelContext, Handler handler) { + VertxPlatformHttpEngine platformEngine = new VertxPlatformHttpEngine(); + platformEngine.setHandlers(Arrays.asList(handler)); + PlatformHttpComponent component = new PlatformHttpComponent(camelContext); + component.setEngine(platformEngine); + camelContext.getRegistry().bind(PlatformHttpConstants.PLATFORM_HTTP_COMPONENT_NAME, component); + } + + private class HitCountHandler implements Handler { + @Override + public void handle(RoutingContext routingContext) { + Session session = routingContext.session(); + Integer cnt = session.get("hitcount"); + cnt = (cnt == null ? 0 : cnt) + 1; + session.put("hitcount", cnt); + routingContext.response().putHeader("hitcount", Integer.toString(cnt)); + routingContext.next(); + } + } + + interface SessionConfigCustomizer { + void customize(VertxPlatformHttpServerConfiguration.SessionConfig sessionConfig); + } +} diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_4.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_4.adoc index 872b1a331b4d0..2d25bf0f26739 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_4.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_4.adoc @@ -87,3 +87,13 @@ The route controller configuration has been moved from general main to its own g All keys started with `camel.springboot.routesController` should be renamed to `camel.routecontroller.`, for example `camel.springboot.routeControllerBackOffDelay` should be renamed to `camel.routecontroller.backOffDelay`. And the option `camel.springboot.routeControllerSuperviseEnabled` has been renamed to `camel.routecontroller.enabled`. + +=== camel-platform-http-vertx + +Added configuration to enable Vert.x session handling. +Sessions are disabled by default, but can be enabled by setting the `enabled` property on `VertxPlatformHttpServerConfiguration.SessionConfig` +to `true`. +Other properties include `sessionCookieName`, `sessionCookiePath`, `sessionTimeout`, `cookieSecure`, `cookieHttpOnly` +`cookieSameSite` and `storeType`. +The session `storeType` defaults to the Vert.x `LocalSessionStore` and `cookieSameSite` to `Strict`. The remainder +of the properties are configured with Vert.x defaults if not set.