From 75ade7371c8258653fd1adf28651dc89d66a8707 Mon Sep 17 00:00:00 2001 From: Martin Kouba Date: Tue, 11 Jun 2024 17:01:47 +0200 Subject: [PATCH 1/6] WebSockets Next: enable traffic logging for debugging purposes - resolves #37947 (cherry picked from commit 94f929a3583d0f0ae2fe064e1086867674a3d3d6) --- .../asciidoc/websockets-next-reference.adoc | 23 +++ .../BasicConnectorTrafficLoggerTest.java | 57 ++++++++ .../ClientEndpointTrafficLoggerTest.java | 72 +++++++++ .../test/traffic/ServerTrafficLoggerTest.java | 42 ++++++ .../next/test/traffic/TrafficLoggerTest.java | 66 +++++++++ .../websockets/next/TrafficLoggingConfig.java | 20 +++ .../next/WebSocketsClientRuntimeConfig.java | 5 + .../next/WebSocketsServerRuntimeConfig.java | 5 + .../runtime/BasicWebSocketConnectorImpl.java | 24 ++- .../websockets/next/runtime/Endpoints.java | 18 ++- .../next/runtime/TrafficLogger.java | 137 ++++++++++++++++++ .../WebSocketClientConnectionImpl.java | 5 +- .../next/runtime/WebSocketConnectionBase.java | 14 +- .../next/runtime/WebSocketConnectionImpl.java | 6 +- .../next/runtime/WebSocketConnectorImpl.java | 12 +- .../next/runtime/WebSocketServerRecorder.java | 13 +- 16 files changed, 491 insertions(+), 28 deletions(-) create mode 100644 extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/traffic/BasicConnectorTrafficLoggerTest.java create mode 100644 extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/traffic/ClientEndpointTrafficLoggerTest.java create mode 100644 extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/traffic/ServerTrafficLoggerTest.java create mode 100644 extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/traffic/TrafficLoggerTest.java create mode 100644 extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/TrafficLoggingConfig.java create mode 100644 extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/TrafficLogger.java diff --git a/docs/src/main/asciidoc/websockets-next-reference.adoc b/docs/src/main/asciidoc/websockets-next-reference.adoc index 537ddf3a996dc..4eae198910817 100644 --- a/docs/src/main/asciidoc/websockets-next-reference.adoc +++ b/docs/src/main/asciidoc/websockets-next-reference.adoc @@ -765,6 +765,29 @@ public class ExampleHttpUpgradeCheck implements HttpUpgradeCheck { TIP: You can choose WebSocket endpoints to which the `HttpUpgradeCheck` is applied with the `HttpUpgradeCheck#appliesTo` method. +[[traffic-logging]] +== Traffic logging + +Quarkus can log the messages sent and received for debugging purposes. +To enable traffic logging, set the `quarkus.websockets-next.server.traffic-logging.enabled` configuration property to `true`. +The payload of text messages is logged as well. +However, the number of logged characters is limited. +The default limit is 100, but you can change this limit with the `quarkus.websockets-next.server.traffic-logging.text-payload-limit` configuration property. + +TIP: The messages are only logged if the `DEBUG` level is enabled for the logger `io.quarkus.websockets.next.traffic`. + +.Example configuration +[source, properties] +---- +quarkus.websockets-next.server.traffic-logging.enabled=true <1> +quarkus.websockets-next.server.traffic-logging.text-payload-limit=50 <2> + +quarkus.log.category."io.quarkus.websockets.next.traffic".level=DEBUG <3> +---- +<1> Enables traffic logging. +<2> Set the number of characters of a text message payload which will be logged. +<3> Enable `DEBUG` level is for the logger `io.quarkus.websockets.next.traffic`. + [[websocket-next-configuration-reference]] == Configuration reference diff --git a/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/traffic/BasicConnectorTrafficLoggerTest.java b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/traffic/BasicConnectorTrafficLoggerTest.java new file mode 100644 index 0000000000000..1333b4c657527 --- /dev/null +++ b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/traffic/BasicConnectorTrafficLoggerTest.java @@ -0,0 +1,57 @@ +package io.quarkus.websockets.next.test.traffic; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import jakarta.inject.Inject; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.websockets.next.BasicWebSocketConnector; +import io.quarkus.websockets.next.WebSocketClientConnection; +import io.vertx.core.Context; + +public class BasicConnectorTrafficLoggerTest extends TrafficLoggerTest { + + @RegisterExtension + public static final QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot(root -> { + root.addClasses(Endpoint.class); + TrafficLoggerTest.addApplicationProperties(root, false); + }) + .setLogRecordPredicate(TrafficLoggerTest::isTrafficLogRecord) + .assertLogRecords(logRecordsConsumer(true)); + + @Inject + BasicWebSocketConnector connector; + + @Test + public void testTrafficLogger() throws InterruptedException { + List messages = new CopyOnWriteArrayList<>(); + CountDownLatch closedLatch = new CountDownLatch(1); + CountDownLatch messageLatch = new CountDownLatch(1); + WebSocketClientConnection conn = connector + .baseUri(endUri) + .path("end") + .onTextMessage((c, m) -> { + assertTrue(Context.isOnWorkerThread()); + messages.add(m); + messageLatch.countDown(); + }) + .onClose((c, s) -> closedLatch.countDown()) + .connectAndAwait(); + conn.sendTextAndAwait("dummy"); + assertTrue(messageLatch.await(5, TimeUnit.SECONDS)); + assertEquals("ok", messages.get(0)); + conn.closeAndAwait(); + assertTrue(closedLatch.await(5, TimeUnit.SECONDS)); + } + +} diff --git a/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/traffic/ClientEndpointTrafficLoggerTest.java b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/traffic/ClientEndpointTrafficLoggerTest.java new file mode 100644 index 0000000000000..598b66748a3c9 --- /dev/null +++ b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/traffic/ClientEndpointTrafficLoggerTest.java @@ -0,0 +1,72 @@ +package io.quarkus.websockets.next.test.traffic; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import jakarta.inject.Inject; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.websockets.next.OnClose; +import io.quarkus.websockets.next.OnTextMessage; +import io.quarkus.websockets.next.WebSocketClient; +import io.quarkus.websockets.next.WebSocketClientConnection; +import io.quarkus.websockets.next.WebSocketConnector; + +public class ClientEndpointTrafficLoggerTest extends TrafficLoggerTest { + + @RegisterExtension + public static final QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot(root -> { + root.addClasses(Endpoint.class, Client.class); + TrafficLoggerTest.addApplicationProperties(root, false); + }) + .setLogRecordPredicate( + TrafficLoggerTest::isTrafficLogRecord) + .assertLogRecords(logRecordsConsumer(true)); + + @Inject + WebSocketConnector connector; + + @Test + public void testTrafficLogger() throws InterruptedException { + WebSocketClientConnection conn = connector + .baseUri(endUri) + .connectAndAwait(); + assertTrue(Client.MESSAGE_LATCH.await(5, TimeUnit.SECONDS)); + assertEquals("ok", Client.MESSAGES.get(0)); + conn.closeAndAwait(); + assertTrue(Client.CLOSED_LATCH.await(5, TimeUnit.SECONDS)); + assertTrue(Endpoint.CLOSED_LATCH.await(5, TimeUnit.SECONDS)); + } + + @WebSocketClient(path = "/end") + public static class Client { + + static final List MESSAGES = new CopyOnWriteArrayList<>(); + + static final CountDownLatch MESSAGE_LATCH = new CountDownLatch(1); + + static final CountDownLatch CLOSED_LATCH = new CountDownLatch(1); + + @OnTextMessage + void onMessage(String message) { + MESSAGES.add(message); + MESSAGE_LATCH.countDown(); + } + + @OnClose + void onClose() { + CLOSED_LATCH.countDown(); + } + + } + +} diff --git a/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/traffic/ServerTrafficLoggerTest.java b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/traffic/ServerTrafficLoggerTest.java new file mode 100644 index 0000000000000..e2832d6bbac64 --- /dev/null +++ b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/traffic/ServerTrafficLoggerTest.java @@ -0,0 +1,42 @@ +package io.quarkus.websockets.next.test.traffic; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +import jakarta.inject.Inject; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.websockets.next.test.utils.WSClient; +import io.vertx.core.Vertx; + +public class ServerTrafficLoggerTest extends TrafficLoggerTest { + + @RegisterExtension + public static final QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot(root -> { + root.addClasses(Endpoint.class, WSClient.class); + TrafficLoggerTest.addApplicationProperties(root, true); + }) + .setLogRecordPredicate(TrafficLoggerTest::isTrafficLogRecord) + .assertLogRecords(logRecordsConsumer(false)); + + @Inject + Vertx vertx; + + @Test + public void testTrafficLogger() throws InterruptedException, ExecutionException { + try (WSClient client = new WSClient(vertx)) { + client.connect(WSClient.toWS(endUri, "end")); + client.waitForMessages(1); + assertEquals("ok", client.getMessages().get(0).toString()); + } + assertTrue(Endpoint.CLOSED_LATCH.await(5, TimeUnit.SECONDS)); + } + +} diff --git a/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/traffic/TrafficLoggerTest.java b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/traffic/TrafficLoggerTest.java new file mode 100644 index 0000000000000..a42dad47a9311 --- /dev/null +++ b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/traffic/TrafficLoggerTest.java @@ -0,0 +1,66 @@ +package io.quarkus.websockets.next.test.traffic; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.function.Consumer; +import java.util.logging.Level; +import java.util.logging.LogRecord; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; + +import io.quarkus.test.common.http.TestHTTPResource; +import io.quarkus.websockets.next.OnClose; +import io.quarkus.websockets.next.OnOpen; +import io.quarkus.websockets.next.WebSocket; + +public abstract class TrafficLoggerTest { + + @TestHTTPResource("/") + URI endUri; + + @WebSocket(path = "/end") + public static class Endpoint { + + static final CountDownLatch CLOSED_LATCH = new CountDownLatch(1); + + @OnOpen + public String open() { + return "ok"; + } + + @OnClose + public void close() { + CLOSED_LATCH.countDown(); + } + + } + + static void addApplicationProperties(JavaArchive archive, boolean server) { + archive.addAsResource(new StringAsset( + "quarkus.websockets-next." + (server ? "server" : "client") + ".traffic-logging.enabled=true\n" + + "quarkus.log.category.\"io.quarkus.websockets.next.traffic\".level=DEBUG"), + "application.properties"); + } + + static Consumer> logRecordsConsumer(boolean received) { + return recs -> { + assertTrue(recs.stream() + .anyMatch(r -> r.getMessage().contains("%s connection opened:"))); + assertTrue(recs.stream() + .anyMatch(r -> r.getMessage() + .contains("%s " + (received ? "received" : "sent") + " text message, Connection[%s]"))); + assertTrue(recs.stream() + .anyMatch(r -> r.getMessage().contains("%s connection closed,"))); + }; + } + + static boolean isTrafficLogRecord(LogRecord r) { + return r.getLevel().equals(Level.FINE) + && r.getLoggerName().equals("io.quarkus.websockets.next.traffic"); + } + +} diff --git a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/TrafficLoggingConfig.java b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/TrafficLoggingConfig.java new file mode 100644 index 0000000000000..7411390eb6d9d --- /dev/null +++ b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/TrafficLoggingConfig.java @@ -0,0 +1,20 @@ +package io.quarkus.websockets.next; + +import io.smallrye.config.WithDefault; + +public interface TrafficLoggingConfig { + + /** + * If set to true then binary/text messages received/sent are logged if the {@code DEBUG} level is enabled for the + * logger {@code io.quarkus.websockets.next.traffic}. + */ + @WithDefault("false") + public boolean enabled(); + + /** + * The number of characters of a text message which will be logged if traffic logging is enabled. The payload of a + * binary message is never logged. + */ + @WithDefault("100") + public int textPayloadLimit(); +} \ No newline at end of file diff --git a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/WebSocketsClientRuntimeConfig.java b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/WebSocketsClientRuntimeConfig.java index b79a9de857853..90c84b47a90c9 100644 --- a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/WebSocketsClientRuntimeConfig.java +++ b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/WebSocketsClientRuntimeConfig.java @@ -58,4 +58,9 @@ public interface WebSocketsClientRuntimeConfig { */ Optional tlsConfigurationName(); + /** + * Traffic logging config. + */ + TrafficLoggingConfig trafficLogging(); + } diff --git a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/WebSocketsServerRuntimeConfig.java b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/WebSocketsServerRuntimeConfig.java index a6bd6679836f3..3d4b71a427dd0 100644 --- a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/WebSocketsServerRuntimeConfig.java +++ b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/WebSocketsServerRuntimeConfig.java @@ -59,6 +59,11 @@ public interface WebSocketsServerRuntimeConfig { */ Security security(); + /** + * Traffic logging config. + */ + TrafficLoggingConfig trafficLogging(); + interface Security { /** diff --git a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/BasicWebSocketConnectorImpl.java b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/BasicWebSocketConnectorImpl.java index d47df577837d3..fb33732ca6c47 100644 --- a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/BasicWebSocketConnectorImpl.java +++ b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/BasicWebSocketConnectorImpl.java @@ -147,12 +147,15 @@ public Uni connect() { return UniHelper.toUni(client.connect(connectOptions)) .map(ws -> { String clientId = BasicWebSocketConnector.class.getName(); + TrafficLogger trafficLogger = TrafficLogger.forClient(config); WebSocketClientConnectionImpl connection = new WebSocketClientConnectionImpl(clientId, ws, codecs, pathParams, serverEndpointUri, - headers); - LOG.debugf("Client connection created: %s", connection); + headers, trafficLogger); + if (trafficLogger != null) { + trafficLogger.connectionOpened(connection); + } connectionManager.add(BasicWebSocketConnectorImpl.class.getName(), connection); if (openHandler != null) { @@ -162,8 +165,11 @@ public Uni connect() { if (textMessageHandler != null) { ws.textMessageHandler(new Handler() { @Override - public void handle(String event) { - doExecute(connection, event, textMessageHandler); + public void handle(String message) { + if (trafficLogger != null) { + trafficLogger.textMessageReceived(connection, message); + } + doExecute(connection, message, textMessageHandler); } }); } @@ -172,8 +178,11 @@ public void handle(String event) { ws.binaryMessageHandler(new Handler() { @Override - public void handle(Buffer event) { - doExecute(connection, event, binaryMessageHandler); + public void handle(Buffer message) { + if (trafficLogger != null) { + trafficLogger.binaryMessageReceived(connection, message); + } + doExecute(connection, message, binaryMessageHandler); } }); } @@ -202,6 +211,9 @@ public void handle(Throwable event) { @Override public void handle(Void event) { + if (trafficLogger != null) { + trafficLogger.connectionClosed(connection); + } if (closeHandler != null) { doExecute(connection, new CloseReason(ws.closeStatusCode(), ws.closeReason()), closeHandler); } diff --git a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/Endpoints.java b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/Endpoints.java index 15980876612be..f3ca9d9247cd7 100644 --- a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/Endpoints.java +++ b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/Endpoints.java @@ -31,7 +31,8 @@ class Endpoints { static void initialize(Vertx vertx, ArcContainer container, Codecs codecs, WebSocketConnectionBase connection, WebSocketBase ws, String generatedEndpointClass, Optional autoPingInterval, - SecuritySupport securitySupport, UnhandledFailureStrategy unhandledFailureStrategy, Runnable onClose) { + SecuritySupport securitySupport, UnhandledFailureStrategy unhandledFailureStrategy, TrafficLogger trafficLogger, + Runnable onClose) { Context context = vertx.getOrCreateContext(); @@ -113,6 +114,9 @@ public void handle(Void event) { if (textBroadcastProcessor == null) { // Multi not consumed - invoke @OnTextMessage callback for each message received textMessageHandler(connection, endpoint, ws, onOpenContext, m -> { + if (trafficLogger != null) { + trafficLogger.textMessageReceived(connection, m); + } endpoint.onTextMessage(m).onComplete(r -> { if (r.succeeded()) { LOG.debugf("@OnTextMessage callback consumed text message: %s", connection); @@ -128,6 +132,9 @@ public void handle(Void event) { contextSupport.start(); securitySupport.start(); try { + if (trafficLogger != null) { + trafficLogger.textMessageReceived(connection, m); + } textBroadcastProcessor.onNext(endpoint.decodeTextMultiItem(m)); LOG.debugf("Text message >> Multi: %s", connection); } catch (Throwable throwable) { @@ -144,6 +151,9 @@ public void handle(Void event) { if (binaryBroadcastProcessor == null) { // Multi not consumed - invoke @OnBinaryMessage callback for each message received binaryMessageHandler(connection, endpoint, ws, onOpenContext, m -> { + if (trafficLogger != null) { + trafficLogger.binaryMessageReceived(connection, m); + } endpoint.onBinaryMessage(m).onComplete(r -> { if (r.succeeded()) { LOG.debugf("@OnBinaryMessage callback consumed binary message: %s", connection); @@ -159,6 +169,9 @@ public void handle(Void event) { contextSupport.start(); securitySupport.start(); try { + if (trafficLogger != null) { + trafficLogger.binaryMessageReceived(connection, m); + } binaryBroadcastProcessor.onNext(endpoint.decodeBinaryMultiItem(m)); LOG.debugf("Binary message >> Multi: %s", connection); } catch (Throwable throwable) { @@ -198,6 +211,9 @@ public void handle(Long timerId) { ws.closeHandler(new Handler() { @Override public void handle(Void event) { + if (trafficLogger != null) { + trafficLogger.connectionClosed(connection); + } ContextSupport.createNewDuplicatedContext(context, connection).runOnContext(new Handler() { @Override public void handle(Void event) { diff --git a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/TrafficLogger.java b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/TrafficLogger.java new file mode 100644 index 0000000000000..4a2a86dd9eadf --- /dev/null +++ b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/TrafficLogger.java @@ -0,0 +1,137 @@ +package io.quarkus.websockets.next.runtime; + +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; + +import org.jboss.logging.Logger; + +import io.quarkus.websockets.next.HandshakeRequest; +import io.quarkus.websockets.next.WebSocketClientConnection; +import io.quarkus.websockets.next.WebSocketConnection; +import io.quarkus.websockets.next.WebSocketsClientRuntimeConfig; +import io.quarkus.websockets.next.WebSocketsServerRuntimeConfig; +import io.vertx.core.buffer.Buffer; + +class TrafficLogger { + + static TrafficLogger forClient(WebSocketsClientRuntimeConfig config) { + return config.trafficLogging().enabled() ? new TrafficLogger(Type.CLIENT, config.trafficLogging().textPayloadLimit()) + : null; + } + + static TrafficLogger forServer(WebSocketsServerRuntimeConfig config) { + return config.trafficLogging().enabled() ? new TrafficLogger(Type.SERVER, config.trafficLogging().textPayloadLimit()) + : null; + } + + private static final Logger LOG = Logger.getLogger("io.quarkus.websockets.next.traffic"); + + private final Type type; + + private final int textPayloadLimit; + + private TrafficLogger(Type type, int textPayloadLimit) { + this.type = type; + this.textPayloadLimit = textPayloadLimit; + } + + void connectionOpened(WebSocketConnectionBase connection) { + if (LOG.isDebugEnabled()) { + LOG.debugf("%s connection opened: %s, Connection[%s], Handshake headers[%s]", + typeToString(), + connection.handshakeRequest().path(), + connectionToString(connection), + headersToString(connection.handshakeRequest())); + } + } + + void textMessageReceived(WebSocketConnectionBase connection, String payload) { + if (LOG.isDebugEnabled()) { + LOG.debugf("%s received text message, Connection[%s], Payload: \n%s", + typeToString(), + connectionToString(connection), + payloadToString(payload)); + } + } + + void textMessageSent(WebSocketConnectionBase connection, String payload) { + if (LOG.isDebugEnabled()) { + LOG.debugf("%s sent text message, Connection[%s], Payload: \n%s", + typeToString(), + connectionToString(connection), + payloadToString(payload)); + } + } + + void binaryMessageReceived(WebSocketConnectionBase connection, Buffer payload) { + if (LOG.isDebugEnabled()) { + LOG.debugf("%s received binary message, Connection[%s], Payload[%s bytes]", + typeToString(), + connectionToString(connection), + payload.length()); + } + } + + void binaryMessageSent(WebSocketConnectionBase connection, Buffer payload) { + if (LOG.isDebugEnabled()) { + LOG.debugf("%s sent binary message, Connection[%s], Payload[%s bytes]", + typeToString(), + connectionToString(connection), + payload.length()); + } + } + + void connectionClosed(WebSocketConnectionBase connection) { + if (LOG.isDebugEnabled()) { + LOG.debugf("%s connection closed, Connection[%s]", + typeToString(), + connectionToString(connection)); + } + } + + private String payloadToString(String payload) { + if (payload == null || payload.isBlank()) { + return "n/a"; + } else if (textPayloadLimit < 0 || payload.length() <= textPayloadLimit) { + return payload; + } else { + return payload.substring(0, payload.length()) + "..."; + } + } + + private String headersToString(HandshakeRequest request) { + Map> headers = request.headers(); + if (headers.isEmpty()) { + return ""; + } + StringBuilder builder = new StringBuilder(); + for (Entry> e : headers.entrySet()) { + for (String value : e.getValue()) { + builder.append(" ").append(e.getKey()).append("=").append(value); + } + } + return builder.toString(); + } + + private String typeToString() { + return type == Type.SERVER ? "[server]" : "[client]"; + } + + private String connectionToString(WebSocketConnectionBase connection) { + StringBuilder builder = new StringBuilder(); + if (connection instanceof WebSocketConnection) { + builder.append("endpointId=").append(((WebSocketConnection) connection).endpointId()); + } else { + builder.append("clientId=").append(((WebSocketClientConnection) connection).clientId()); + } + builder.append(", id=").append(connection.id()); + return builder.toString(); + } + + enum Type { + SERVER, + CLIENT + } + +} diff --git a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketClientConnectionImpl.java b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketClientConnectionImpl.java index 83b9745ab7cf5..040f2df87e097 100644 --- a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketClientConnectionImpl.java +++ b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketClientConnectionImpl.java @@ -19,8 +19,9 @@ class WebSocketClientConnectionImpl extends WebSocketConnectionBase implements W private final WebSocket webSocket; WebSocketClientConnectionImpl(String clientId, WebSocket webSocket, Codecs codecs, - Map pathParams, URI serverEndpointUri, Map> headers) { - super(Map.copyOf(pathParams), codecs, new ClientHandshakeRequestImpl(serverEndpointUri, headers)); + Map pathParams, URI serverEndpointUri, Map> headers, + TrafficLogger trafficLogger) { + super(Map.copyOf(pathParams), codecs, new ClientHandshakeRequestImpl(serverEndpointUri, headers), trafficLogger); this.clientId = clientId; this.webSocket = Objects.requireNonNull(webSocket); } diff --git a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketConnectionBase.java b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketConnectionBase.java index 8c40b5ff759fd..4a0749a4f875c 100644 --- a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketConnectionBase.java +++ b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketConnectionBase.java @@ -32,12 +32,16 @@ public abstract class WebSocketConnectionBase { protected final Instant creationTime; - WebSocketConnectionBase(Map pathParams, Codecs codecs, HandshakeRequest handshakeRequest) { + protected final TrafficLogger trafficLogger; + + WebSocketConnectionBase(Map pathParams, Codecs codecs, HandshakeRequest handshakeRequest, + TrafficLogger trafficLogger) { this.identifier = UUID.randomUUID().toString(); this.pathParams = pathParams; this.codecs = codecs; this.handshakeRequest = handshakeRequest; this.creationTime = Instant.now(); + this.trafficLogger = trafficLogger; } abstract WebSocketBase webSocket(); @@ -51,11 +55,15 @@ public String pathParam(String name) { } public Uni sendText(String message) { - return UniHelper.toUni(webSocket().writeTextMessage(message)); + Uni uni = UniHelper.toUni(webSocket().writeTextMessage(message)); + return trafficLogger == null ? uni : uni.invoke(() -> { + trafficLogger.textMessageSent(this, message); + }); } public Uni sendBinary(Buffer message) { - return UniHelper.toUni(webSocket().writeBinaryMessage(message)); + Uni uni = UniHelper.toUni(webSocket().writeBinaryMessage(message)); + return trafficLogger == null ? uni : uni.invoke(() -> trafficLogger.binaryMessageSent(this, message)); } public Uni sendText(M message) { diff --git a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketConnectionImpl.java b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketConnectionImpl.java index 124fc48bdab6b..d1d4cad07638e 100644 --- a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketConnectionImpl.java +++ b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketConnectionImpl.java @@ -33,8 +33,8 @@ class WebSocketConnectionImpl extends WebSocketConnectionBase implements WebSock WebSocketConnectionImpl(String generatedEndpointClass, String endpointClass, ServerWebSocket webSocket, ConnectionManager connectionManager, - Codecs codecs, RoutingContext ctx) { - super(Map.copyOf(ctx.pathParams()), codecs, new HandshakeRequestImpl(webSocket, ctx)); + Codecs codecs, RoutingContext ctx, TrafficLogger trafficLogger) { + super(Map.copyOf(ctx.pathParams()), codecs, new HandshakeRequestImpl(webSocket, ctx), trafficLogger); this.generatedEndpointClass = generatedEndpointClass; this.endpointId = endpointClass; this.webSocket = Objects.requireNonNull(webSocket); @@ -70,7 +70,7 @@ public String subprotocol() { @Override public String toString() { - return "WebSocket connection [id=" + identifier + ", path=" + webSocket.path() + "]"; + return "WebSocket connection [endpointId=" + endpointId + ", path=" + webSocket.path() + ", id=" + identifier + "]"; } @Override diff --git a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketConnectorImpl.java b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketConnectorImpl.java index be39e41799564..359a400f5160a 100644 --- a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketConnectorImpl.java +++ b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketConnectorImpl.java @@ -13,7 +13,6 @@ import jakarta.enterprise.inject.spi.InjectionPoint; import org.eclipse.microprofile.config.ConfigProvider; -import org.jboss.logging.Logger; import io.quarkus.arc.Arc; import io.quarkus.tls.TlsConfigurationRegistry; @@ -34,8 +33,6 @@ public class WebSocketConnectorImpl extends WebSocketConnectorBase> implements WebSocketConnector { - private static final Logger LOG = Logger.getLogger(WebSocketConnectorImpl.class); - // derived properties private final ClientEndpoint clientEndpoint; @@ -97,16 +94,19 @@ public Uni connect() { return UniHelper.toUni(client.connect(connectOptions)) .map(ws -> { + TrafficLogger trafficLogger = TrafficLogger.forClient(config); WebSocketClientConnectionImpl connection = new WebSocketClientConnectionImpl(clientEndpoint.clientId, ws, codecs, pathParams, - serverEndpointUri, headers); - LOG.debugf("Client connection created: %s", connection); + serverEndpointUri, headers, trafficLogger); + if (trafficLogger != null) { + trafficLogger.connectionOpened(connection); + } connectionManager.add(clientEndpoint.generatedEndpointClass, connection); Endpoints.initialize(vertx, Arc.container(), codecs, connection, ws, clientEndpoint.generatedEndpointClass, config.autoPingInterval(), SecuritySupport.NOOP, - config.unhandledFailureStrategy(), + config.unhandledFailureStrategy(), trafficLogger, () -> { connectionManager.remove(clientEndpoint.generatedEndpointClass, connection); client.close(); diff --git a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketServerRecorder.java b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketServerRecorder.java index 4a14a2cd80d40..6deedde0f6409 100644 --- a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketServerRecorder.java +++ b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketServerRecorder.java @@ -8,8 +8,6 @@ import jakarta.enterprise.inject.Instance; -import org.jboss.logging.Logger; - import io.quarkus.arc.Arc; import io.quarkus.arc.ArcContainer; import io.quarkus.runtime.annotations.Recorder; @@ -36,8 +34,6 @@ @Recorder public class WebSocketServerRecorder { - private static final Logger LOG = Logger.getLogger(WebSocketServerRecorder.class); - private final WebSocketsServerRuntimeConfig config; public WebSocketServerRecorder(WebSocketsServerRuntimeConfig config) { @@ -94,6 +90,7 @@ public Handler createEndpointHandler(String generatedEndpointCla ConnectionManager connectionManager = container.instance(ConnectionManager.class).get(); Codecs codecs = container.instance(Codecs.class).get(); HttpUpgradeCheck[] httpUpgradeChecks = getHttpUpgradeChecks(endpointId, container); + TrafficLogger trafficLogger = TrafficLogger.forServer(config); return new Handler() { @Override @@ -121,14 +118,16 @@ private void httpUpgrade(RoutingContext ctx) { Vertx vertx = VertxCoreRecorder.getVertx().get(); WebSocketConnectionImpl connection = new WebSocketConnectionImpl(generatedEndpointClass, endpointId, ws, - connectionManager, codecs, ctx); + connectionManager, codecs, ctx, trafficLogger); connectionManager.add(generatedEndpointClass, connection); - LOG.debugf("Connection created: %s", connection); + if (trafficLogger != null) { + trafficLogger.connectionOpened(connection); + } SecuritySupport securitySupport = initializeSecuritySupport(container, ctx, vertx, connection); Endpoints.initialize(vertx, container, codecs, connection, ws, generatedEndpointClass, - config.autoPingInterval(), securitySupport, config.unhandledFailureStrategy(), + config.autoPingInterval(), securitySupport, config.unhandledFailureStrategy(), trafficLogger, () -> connectionManager.remove(generatedEndpointClass, connection)); }); } From f89e535f75a140dece3bcd099a063eb7734ed5cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20=C3=89pardaud?= Date: Tue, 11 Jun 2024 15:56:12 +0200 Subject: [PATCH 2/6] Fix two Date issues regarding preconditions - Round Dates to second precision when comparing them - Lookup header delegate for Date and subtypes Fixes #41110 (cherry picked from commit 07b3f949d3df35f796dc3124e1e3f72249fe1502) --- .../preconditions/DatePreconditionTests.java | 63 +++++++++++++++++++ .../common/jaxrs/RuntimeDelegateImpl.java | 5 +- .../reactive/server/jaxrs/RequestImpl.java | 28 ++++++--- 3 files changed, 88 insertions(+), 8 deletions(-) create mode 100644 extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/preconditions/DatePreconditionTests.java diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/preconditions/DatePreconditionTests.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/preconditions/DatePreconditionTests.java new file mode 100644 index 0000000000000..8b8968bd2e484 --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/preconditions/DatePreconditionTests.java @@ -0,0 +1,63 @@ +package io.quarkus.resteasy.reactive.server.test.preconditions; + +import static io.restassured.RestAssured.get; + +import java.time.Instant; +import java.util.Date; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Request; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.ResponseBuilder; + +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.restassured.RestAssured; + +public class DatePreconditionTests { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(Resource.class)); + + // Make sure we test a subtype of Date, since that is what Hibernate ORM gives us most of the time (hah) + // Also make sure we have non-zero milliseconds, since that will be the case for most date values representing + // "now", and we want to make sure pre-conditions work (second-resolution) + static final Date date = new Date(Date.from(Instant.parse("2007-12-03T10:15:30.24Z")).getTime()) { + }; + + public static class Something { + } + + @Test + public void test() { + get("/preconditions") + .then() + .statusCode(200) + .header("Last-Modified", "Mon, 03 Dec 2007 10:15:30 GMT") + .body(Matchers.equalTo("foo")); + RestAssured + .with() + .header("If-Modified-Since", "Mon, 03 Dec 2007 10:15:30 GMT") + .get("/preconditions") + .then() + .statusCode(304); + } + + @Path("/preconditions") + public static class Resource { + @GET + public Response get(Request request) { + ResponseBuilder resp = request.evaluatePreconditions(date); + if (resp != null) { + return resp.build(); + } + return Response.ok("foo").lastModified(date).build(); + } + } +} diff --git a/independent-projects/resteasy-reactive/common/runtime/src/main/java/org/jboss/resteasy/reactive/common/jaxrs/RuntimeDelegateImpl.java b/independent-projects/resteasy-reactive/common/runtime/src/main/java/org/jboss/resteasy/reactive/common/jaxrs/RuntimeDelegateImpl.java index 252ab655a3bb4..98039bb181a7a 100644 --- a/independent-projects/resteasy-reactive/common/runtime/src/main/java/org/jboss/resteasy/reactive/common/jaxrs/RuntimeDelegateImpl.java +++ b/independent-projects/resteasy-reactive/common/runtime/src/main/java/org/jboss/resteasy/reactive/common/jaxrs/RuntimeDelegateImpl.java @@ -95,7 +95,10 @@ public HeaderDelegate createHeaderDelegate(Class type) throws IllegalA } if (type.equals(MediaType.class)) { return (HeaderDelegate) MediaTypeHeaderDelegate.INSTANCE; - } else if (type.equals(Date.class)) { + } else if (Date.class.isAssignableFrom(type)) { + // for Date, we do subtypes too, because ORM will instantiate java.util.Date as subtypes + // and it's extremely likely we get those here, and we still have to generate a valid + // date representation for them, rather than Object.toString which will be wrong return (HeaderDelegate) DateDelegate.INSTANCE; } else if (type.equals(CacheControl.class)) { return (HeaderDelegate) CacheControlDelegate.INSTANCE; diff --git a/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/jaxrs/RequestImpl.java b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/jaxrs/RequestImpl.java index 9a33e0ea8c7e7..8b4e923712d01 100644 --- a/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/jaxrs/RequestImpl.java +++ b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/jaxrs/RequestImpl.java @@ -36,6 +36,7 @@ private boolean isRfc7232preconditions() { return true;//todo: do we need config for this? } + @Override public Variant selectVariant(List variants) throws IllegalArgumentException { if (variants == null || variants.size() == 0) throw new IllegalArgumentException("Variant list must not be empty"); @@ -53,7 +54,7 @@ public Variant selectVariant(List variants) throws IllegalArgumentExcep return negotiation.getBestMatch(variants); } - public List convertEtag(List tags) { + private List convertEtag(List tags) { ArrayList result = new ArrayList(); for (String tag : tags) { String[] split = tag.split(","); @@ -64,7 +65,7 @@ public List convertEtag(List tags) { return result; } - public Response.ResponseBuilder ifMatch(List ifMatch, EntityTag eTag) { + private Response.ResponseBuilder ifMatch(List ifMatch, EntityTag eTag) { boolean match = false; for (EntityTag tag : ifMatch) { if (tag.equals(eTag) || tag.getValue().equals("*")) { @@ -78,7 +79,7 @@ public Response.ResponseBuilder ifMatch(List ifMatch, EntityTag eTag) } - public Response.ResponseBuilder ifNoneMatch(List ifMatch, EntityTag eTag) { + private Response.ResponseBuilder ifNoneMatch(List ifMatch, EntityTag eTag) { boolean match = false; for (EntityTag tag : ifMatch) { if (tag.equals(eTag) || tag.getValue().equals("*")) { @@ -96,6 +97,7 @@ public Response.ResponseBuilder ifNoneMatch(List ifMatch, EntityTag e return null; } + @Override public Response.ResponseBuilder evaluatePreconditions(EntityTag eTag) { if (eTag == null) throw new IllegalArgumentException("ETag was null"); @@ -118,26 +120,36 @@ public Response.ResponseBuilder evaluatePreconditions(EntityTag eTag) { return builder; } - public Response.ResponseBuilder ifModifiedSince(String strDate, Date lastModified) { + private Response.ResponseBuilder ifModifiedSince(String strDate, Date lastModified) { Date date = DateUtil.parseDate(strDate); - if (date.getTime() >= lastModified.getTime()) { + if (date.getTime() >= millisecondsWithSecondsPrecision(lastModified)) { return Response.notModified(); } return null; } - public Response.ResponseBuilder ifUnmodifiedSince(String strDate, Date lastModified) { + private Response.ResponseBuilder ifUnmodifiedSince(String strDate, Date lastModified) { Date date = DateUtil.parseDate(strDate); - if (date.getTime() >= lastModified.getTime()) { + if (date.getTime() >= millisecondsWithSecondsPrecision(lastModified)) { return null; } return Response.status(Response.Status.PRECONDITION_FAILED).lastModified(lastModified); } + /** + * We must compare header dates (seconds-precision) with dates that have the same precision, + * otherwise they may include milliseconds and they will never match the Last-Modified + * values that we generate from them (since we drop their milliseconds when we write the headers) + */ + private long millisecondsWithSecondsPrecision(Date lastModified) { + return (lastModified.getTime() / 1000) * 1000; + } + + @Override public Response.ResponseBuilder evaluatePreconditions(Date lastModified) { if (lastModified == null) throw new IllegalArgumentException("Param cannot be null"); @@ -159,6 +171,7 @@ public Response.ResponseBuilder evaluatePreconditions(Date lastModified) { return builder; } + @Override public Response.ResponseBuilder evaluatePreconditions(Date lastModified, EntityTag eTag) { if (lastModified == null) throw new IllegalArgumentException("Last modified was null"); @@ -182,6 +195,7 @@ else if (lastModifiedBuilder == null && etagBuilder != null) return rtn; } + @Override public Response.ResponseBuilder evaluatePreconditions() { List ifMatch = requestContext.getHttpHeaders().getRequestHeaders().get(HttpHeaders.IF_MATCH); if (ifMatch == null || ifMatch.size() == 0) { From 9cd8387848965c0e13524a9e6d782948b26c9cde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Vav=C5=99=C3=ADk?= Date: Thu, 13 Jun 2024 19:49:25 +0200 Subject: [PATCH 3/6] Fix mixing @TestSecurity with HTTP request credentials (cherry picked from commit 5164af2d01afa2f7b365afcb8f0eb92e46233971) --- docs/src/main/asciidoc/security-testing.adoc | 37 +++++++++++ .../quarkus/it/keycloak/TenantResource.java | 9 +++ .../src/main/resources/application.properties | 2 +- .../BearerTokenAuthorizationTest.java | 4 +- .../TestSecurityCombiningAuthMechTest.java | 61 ++++++++++++++++++- ...tractTestHttpAuthenticationMechanism.java} | 11 +--- ...llbackTestHttpAuthenticationMechanism.java | 13 ++++ ...hBasedTestHttpAuthenticationMechanism.java | 60 ++++++++++++++++++ .../QuarkusSecurityTestExtension.java | 9 ++- 9 files changed, 190 insertions(+), 16 deletions(-) rename test-framework/security/src/main/java/io/quarkus/test/security/{TestHttpAuthenticationMechanism.java => AbstractTestHttpAuthenticationMechanism.java} (87%) create mode 100644 test-framework/security/src/main/java/io/quarkus/test/security/FallbackTestHttpAuthenticationMechanism.java create mode 100644 test-framework/security/src/main/java/io/quarkus/test/security/PathBasedTestHttpAuthenticationMechanism.java diff --git a/docs/src/main/asciidoc/security-testing.adoc b/docs/src/main/asciidoc/security-testing.adoc index bc79dc86e6571..fa84c80d55444 100644 --- a/docs/src/main/asciidoc/security-testing.adoc +++ b/docs/src/main/asciidoc/security-testing.adoc @@ -139,6 +139,43 @@ If it becomes necessary to test security features using both `@TestSecurity` and mechanism when none is defined), then Basic Auth needs to be enabled explicitly, for example by setting `quarkus.http.auth.basic=true` or `%test.quarkus.http.auth.basic=true`. +Similarly, if it becomes necessary to test security features using both `@TestSecurity` and Bearer token authentication, +you can leverage both like in the example below: + +[source, java] +---- +@Test +@TestSecurity(user = "Bob") +public void testSecurityMetaAnnotation { + RestAssured.given() + .auth().oauth2(getTokenForUser("Alice")) <1> + .get("hello") + .then() + .statusCode(200) + .body(Matchers.is("Hello Alice")); + RestAssured.given() + .get("hello") + .then() + .statusCode(200) + .body(Matchers.is("Hello Bob")); <2> +} + +@Path("hello") +public static class HelloResource { + + @Inject + SecurityIdentity identity; + + @Authenticated + @GET + public String sayHello() { + return "Hello " + identity.getPrincipal().getName(); + } +} +---- +<1> Bearer token authentication mechanism is used, because a Bearer access token is sent with the HTTP request. +<2> No authorization header is present, therefore the Test Security Extension creates user `Bob`. + === Path-based authentication `@TestSecurity` can also be used when xref:security-authentication-mechanisms.adoc#combining-authentication-mechanisms[authentication mechanisms must be combined]. diff --git a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantResource.java b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantResource.java index 9d2565e7cd201..3121f0e8ed051 100644 --- a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantResource.java +++ b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantResource.java @@ -197,6 +197,15 @@ public String scopePermissionsWebApp2(@PathParam("tenant") String tenant) { .collect(Collectors.joining(" ")); } + @AuthorizationCodeFlow + @GET + @Path("webapp-local-logout") + @RolesAllowed("user") + public String localLogout() { + oidcSession.logout().await().indefinitely(); + return securityIdentity.getPrincipal().getName(); + } + private String getNameWebAppType(String name, String idTokenNameClaim, String idTokenNameClaimNotExpected) { diff --git a/integration-tests/oidc-tenancy/src/main/resources/application.properties b/integration-tests/oidc-tenancy/src/main/resources/application.properties index 85b6a546f18f3..11b4b1d989edd 100644 --- a/integration-tests/oidc-tenancy/src/main/resources/application.properties +++ b/integration-tests/oidc-tenancy/src/main/resources/application.properties @@ -54,7 +54,7 @@ quarkus.oidc.tenant-web-app.application-type=web-app quarkus.oidc.tenant-web-app.roles.source=userinfo quarkus.oidc.tenant-web-app.allow-user-info-cache=false # Adding this property should not affect the flow if no expected request header -# "HX-Request" identifiying it as a JavaScript request is found +# "HX-Request" identifying it as a JavaScript request is found quarkus.oidc.tenant-web-app.authentication.java-script-auto-redirect=false # Tenant Web App Java Script diff --git a/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java b/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java index fc2c666eb54b2..bed385e6e7bb5 100644 --- a/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java +++ b/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java @@ -905,7 +905,7 @@ private String getOpaqueAccessTokenFromSimpleOidc() { return object.getString("access_token"); } - private WebClient createWebClient() { + static WebClient createWebClient() { WebClient webClient = new WebClient(); webClient.setCssErrorHandler(new SilentCssErrorHandler()); return webClient; @@ -921,7 +921,7 @@ private Cookie getStateCookie(WebClient webClient, String tenantId) { return null; } - private Cookie getSessionCookie(WebClient webClient, String tenantId) { + static Cookie getSessionCookie(WebClient webClient, String tenantId) { return webClient.getCookieManager().getCookie("q_session" + (tenantId == null ? "" : "_" + tenantId)); } diff --git a/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/TestSecurityCombiningAuthMechTest.java b/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/TestSecurityCombiningAuthMechTest.java index 8c1f6bf5a6579..21d5636f81f95 100644 --- a/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/TestSecurityCombiningAuthMechTest.java +++ b/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/TestSecurityCombiningAuthMechTest.java @@ -1,17 +1,31 @@ package io.quarkus.it.keycloak; +import static io.quarkus.it.keycloak.AnnotationBasedTenantTest.getTokenWithRole; +import static io.quarkus.it.keycloak.BearerTokenAuthorizationTest.createWebClient; +import static io.quarkus.it.keycloak.BearerTokenAuthorizationTest.getSessionCookie; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.io.IOException; + +import org.hamcrest.Matchers; +import org.htmlunit.WebClient; +import org.htmlunit.html.HtmlForm; +import org.htmlunit.html.HtmlPage; import org.junit.jupiter.api.Test; +import io.quarkus.test.common.QuarkusTestResource; import io.quarkus.test.common.http.TestHTTPEndpoint; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.security.TestSecurity; import io.restassured.RestAssured; import io.restassured.http.ContentType; -@TestHTTPEndpoint(MultipleAuthMechResource.class) @QuarkusTest +@QuarkusTestResource(KeycloakRealmResourceManager.class) public class TestSecurityCombiningAuthMechTest { + @TestHTTPEndpoint(MultipleAuthMechResource.class) @TestSecurity(user = "testUser", authMechanism = "basic") @Test public void testBasicAuthentication() { @@ -43,6 +57,7 @@ public void testBasicAuthentication() { .statusCode(401); } + @TestHTTPEndpoint(MultipleAuthMechResource.class) @TestSecurity(user = "testUser", authMechanism = "Bearer") @Test public void testBearerBasedAuthentication() { @@ -72,6 +87,7 @@ public void testBearerBasedAuthentication() { .statusCode(200); } + @TestHTTPEndpoint(MultipleAuthMechResource.class) @TestSecurity(user = "testUser", authMechanism = "custom") @Test public void testCustomAuthentication() { @@ -102,4 +118,47 @@ public void testCustomAuthentication() { .then() .statusCode(401); } + + @TestHTTPEndpoint(TenantEchoResource.class) + @TestSecurity(user = "testUser", authMechanism = "Bearer", roles = "role1") + @Test + public void testHttpCredentialsHasPriorityOverTestSecurity() { + // token has priority over @TestSecurity + RestAssured.given().auth().oauth2(getTokenWithRole("role1")) + .when().get("jax-rs-perm-check") + .then().statusCode(200) + .body(Matchers.equalTo(("tenant-id=tenant-public-key, static.tenant.id=tenant-public-key, name=alice"))); + // no token -> use @TestSecurity + RestAssured.given() + .when().get("jax-rs-perm-check") + .then().statusCode(200) + .body(Matchers.equalTo(("tenant-id=tenant-public-key, static.tenant.id=null, name=testUser"))); + } + + @TestSecurity(user = "testUser", authMechanism = "Bearer", roles = "role1") + @Test + public void testSessionCookieHasPriorityOverTestSecurity() throws IOException { + // @TestSecurity still use Bearer authentication as we didn't specify credentials + RestAssured.given() + .redirects().follow(false) + .when().get("/tenant/tenant-web-app/api/user/webapp-local-logout") + .then().statusCode(302); + RestAssured.given() + .when().get("/api/tenant-echo/jax-rs-perm-check") + .then().statusCode(200) + .body(Matchers.equalTo(("tenant-id=tenant-public-key, static.tenant.id=null, name=testUser"))); + + // path specific authentication is still possible, the @TestSecurity is not used as it uses Bearer, not code + try (final WebClient webClient = createWebClient()) { + HtmlPage page = webClient.getPage("http://localhost:8081/tenant/tenant-web-app/api/user/webapp-local-logout"); + assertNull(getSessionCookie(webClient, "tenant-web-app")); + assertEquals("Sign in to quarkus-webapp", page.getTitleText()); + HtmlForm loginForm = page.getForms().get(0); + loginForm.getInputByName("username").setValueAttribute("alice"); + loginForm.getInputByName("password").setValueAttribute("alice"); + page = loginForm.getInputByName("login").click(); + assertEquals("alice", page.getBody().asNormalizedText()); + assertNull(getSessionCookie(webClient, "tenant-web-app")); + } + } } diff --git a/test-framework/security/src/main/java/io/quarkus/test/security/TestHttpAuthenticationMechanism.java b/test-framework/security/src/main/java/io/quarkus/test/security/AbstractTestHttpAuthenticationMechanism.java similarity index 87% rename from test-framework/security/src/main/java/io/quarkus/test/security/TestHttpAuthenticationMechanism.java rename to test-framework/security/src/main/java/io/quarkus/test/security/AbstractTestHttpAuthenticationMechanism.java index 1bf6a853d50d6..1359b83e041f7 100644 --- a/test-framework/security/src/main/java/io/quarkus/test/security/TestHttpAuthenticationMechanism.java +++ b/test-framework/security/src/main/java/io/quarkus/test/security/AbstractTestHttpAuthenticationMechanism.java @@ -4,7 +4,6 @@ import java.util.Set; import jakarta.annotation.PostConstruct; -import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import io.quarkus.runtime.LaunchMode; @@ -17,13 +16,12 @@ import io.smallrye.mutiny.Uni; import io.vertx.ext.web.RoutingContext; -@ApplicationScoped -public class TestHttpAuthenticationMechanism implements HttpAuthenticationMechanism { +abstract class AbstractTestHttpAuthenticationMechanism implements HttpAuthenticationMechanism { @Inject TestIdentityAssociation testIdentityAssociation; - volatile String authMechanism = null; + protected volatile String authMechanism = null; @PostConstruct public void check() { @@ -54,11 +52,6 @@ public Uni getCredentialTransport(RoutingContext contex : Uni.createFrom().item(new HttpCredentialTransport(HttpCredentialTransport.Type.TEST_SECURITY, authMechanism)); } - @Override - public int getPriority() { - return 3000; - } - void setAuthMechanism(String authMechanism) { this.authMechanism = authMechanism; } diff --git a/test-framework/security/src/main/java/io/quarkus/test/security/FallbackTestHttpAuthenticationMechanism.java b/test-framework/security/src/main/java/io/quarkus/test/security/FallbackTestHttpAuthenticationMechanism.java new file mode 100644 index 0000000000000..69c79a5d758da --- /dev/null +++ b/test-framework/security/src/main/java/io/quarkus/test/security/FallbackTestHttpAuthenticationMechanism.java @@ -0,0 +1,13 @@ +package io.quarkus.test.security; + +import jakarta.enterprise.context.ApplicationScoped; + +/** + * This test mechanism is fallback when no other mechanism manages to authenticate. + * When the test method is annotated with the {@link TestSecurity} annotation, + * users can still send credentials inside HTTP request and the credentials will have priority. + */ +@ApplicationScoped +public class FallbackTestHttpAuthenticationMechanism extends AbstractTestHttpAuthenticationMechanism { + +} diff --git a/test-framework/security/src/main/java/io/quarkus/test/security/PathBasedTestHttpAuthenticationMechanism.java b/test-framework/security/src/main/java/io/quarkus/test/security/PathBasedTestHttpAuthenticationMechanism.java new file mode 100644 index 0000000000000..fac1f7d467181 --- /dev/null +++ b/test-framework/security/src/main/java/io/quarkus/test/security/PathBasedTestHttpAuthenticationMechanism.java @@ -0,0 +1,60 @@ +package io.quarkus.test.security; + +import static io.netty.handler.codec.http.HttpHeaderNames.AUTHORIZATION; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.security.identity.IdentityProviderManager; +import io.quarkus.security.identity.SecurityIdentity; +import io.smallrye.mutiny.Uni; +import io.vertx.core.http.Cookie; +import io.vertx.ext.web.RoutingContext; + +/** + * When authentication mechanism is selected with the {@link TestSecurity#authMechanism()} annotation attribute, + * we must be sure that the test mechanism is primary identity provider for that authentication type. + *

+ * For example when a test method is annotated with `@TestSecurity(authMechanism = "basic")`, + * we want to be the ones providing basic authentication when no authorization headers are present, + * and not the {@link io.quarkus.vertx.http.runtime.security.BasicAuthenticationMechanism} mechanism. + * This test mechanism must exist because when a path-specific authentication mechanism is selected, + * for example via {@link io.quarkus.vertx.http.runtime.security.annotation.BasicAuthentication}, + * it is also required and therefore exactly one mechanism is enforced. + */ +@ApplicationScoped +public class PathBasedTestHttpAuthenticationMechanism extends AbstractTestHttpAuthenticationMechanism { + + @Override + public Uni authenticate(RoutingContext context, IdentityProviderManager identityProviderManager) { + if (authMechanism != null && requestNotAuthenticated(context)) { + // return the SecurityIdentity defined via @TestSecurity + return super.authenticate(context, identityProviderManager); + } + // do not authenticate - give a change to other mechanisms + return Uni.createFrom().nullItem(); + } + + @Override + public int getPriority() { + return 3000; + } + + private static boolean requestNotAuthenticated(RoutingContext context) { + // on a best-effort basis try to guess whether incoming request is authorized + return context.request().getHeader(AUTHORIZATION) == null + && !hasOidcSessionCookieCandidate(context); + } + + private static boolean hasOidcSessionCookieCandidate(RoutingContext context) { + if (context.request().cookies() == null) { + return false; + } + for (Cookie cookie : context.request().cookies()) { + if (cookie.getName() != null && cookie.getName().startsWith("q_session")) { + // there is a possibility this is an OIDC session cookie + return true; + } + } + return false; + } +} diff --git a/test-framework/security/src/main/java/io/quarkus/test/security/QuarkusSecurityTestExtension.java b/test-framework/security/src/main/java/io/quarkus/test/security/QuarkusSecurityTestExtension.java index 44a0bc2750278..b574d1286b43e 100644 --- a/test-framework/security/src/main/java/io/quarkus/test/security/QuarkusSecurityTestExtension.java +++ b/test-framework/security/src/main/java/io/quarkus/test/security/QuarkusSecurityTestExtension.java @@ -26,7 +26,9 @@ public void afterEach(QuarkusTestMethodContext context) { try { if (getAnnotationContainer(context).isPresent()) { CDI.current().select(TestAuthController.class).get().setEnabled(true); - CDI.current().select(TestHttpAuthenticationMechanism.class).get().setAuthMechanism(null); + for (var testMechanism : CDI.current().select(AbstractTestHttpAuthenticationMechanism.class)) { + testMechanism.setAuthMechanism(null); + } var testIdentity = CDI.current().select(TestIdentityAssociation.class).get(); testIdentity.setTestIdentity(null); testIdentity.setPathBasedIdentity(false); @@ -66,8 +68,9 @@ public void beforeEach(QuarkusTestMethodContext context) { SecurityIdentity userIdentity = augment(user.build(), allAnnotations); CDI.current().select(TestIdentityAssociation.class).get().setTestIdentity(userIdentity); if (!testSecurity.authMechanism().isEmpty()) { - CDI.current().select(TestHttpAuthenticationMechanism.class).get() - .setAuthMechanism(testSecurity.authMechanism()); + for (var testMechanism : CDI.current().select(AbstractTestHttpAuthenticationMechanism.class)) { + testMechanism.setAuthMechanism(testSecurity.authMechanism()); + } CDI.current().select(TestIdentityAssociation.class).get().setPathBasedIdentity(true); } } From 247d1f8b7dbe4201ec8a923721aceb645d5dadba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Vav=C5=99=C3=ADk?= Date: Thu, 13 Jun 2024 22:33:59 +0200 Subject: [PATCH 4/6] Fix WS Next CDI events documentation (cherry picked from commit 2104846e138f9936579af983c644e5219c37e507) --- docs/src/main/asciidoc/websockets-next-reference.adoc | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/src/main/asciidoc/websockets-next-reference.adoc b/docs/src/main/asciidoc/websockets-next-reference.adoc index 4eae198910817..11e14ba18a2c4 100644 --- a/docs/src/main/asciidoc/websockets-next-reference.adoc +++ b/docs/src/main/asciidoc/websockets-next-reference.adoc @@ -523,18 +523,18 @@ For example, `OpenConnections#findByEndpointId(String)` makes it easy to find co === CDI events -Quarkus fires a CDI event of type `io.quarkus.websockets.next.WebSocketConnection` with qualifier `@io.quarkus.websockets.next.ConnectionOpen` asynchronously when a new connection is opened. -Moreover, a CDI event of type `WebSocketConnection` with qualifier `@io.quarkus.websockets.next.ConnectionClosed` is fired asynchronously when a connection is closed. +Quarkus fires a CDI event of type `io.quarkus.websockets.next.WebSocketConnection` with qualifier `@io.quarkus.websockets.next.Open` asynchronously when a new connection is opened. +Moreover, a CDI event of type `WebSocketConnection` with qualifier `@io.quarkus.websockets.next.Closed` is fired asynchronously when a connection is closed. [source, java] ---- import jakarta.enterprise.event.ObservesAsync; -import io.quarkus.websockets.next.ConnectionOpen; +import io.quarkus.websockets.next.Open; import io.quarkus.websockets.next.WebSocketConnection; class MyBean { - void connectionOpened(@ObservesAsync @ConnectionOpen WebSocketConnection connection) { <1> + void connectionOpened(@ObservesAsync @Open WebSocketConnection connection) { <1> // This observer method is called when a connection is opened... } } From f669dd57de276f5383941ce2165f4d6b4924eaf5 Mon Sep 17 00:00:00 2001 From: Martin Kouba Date: Fri, 14 Jun 2024 13:01:17 +0200 Subject: [PATCH 5/6] QuarkusComponentTest: fix NPE if component is expected but none exists - related to #41200 (cherry picked from commit 45bd1bb890447f2e5b5a5baf0c92a38f744936ca) --- .../component/QuarkusComponentTestExtension.java | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentTestExtension.java b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentTestExtension.java index 4516f822f1e8f..cd143b17d9abd 100644 --- a/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentTestExtension.java +++ b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentTestExtension.java @@ -1097,18 +1097,25 @@ public FieldInjector(Field field, Object testInstance) throws Exception { } else { InstanceHandle handle = container.instance(requiredType, qualifiers); if (field.isAnnotationPresent(Inject.class)) { + if (!handle.isAvailable()) { + throw new IllegalStateException(String + .format("The injected field [%s] expects a real component; but no matching component was registered", + field, + handle.getBean())); + } if (handle.getBean().getKind() == io.quarkus.arc.InjectableBean.Kind.SYNTHETIC) { throw new IllegalStateException(String - .format("The injected field %s expects a real component; but obtained: %s", field, + .format("The injected field [%s] expects a real component; but obtained: %s", field, handle.getBean())); } } else { if (!handle.isAvailable()) { throw new IllegalStateException(String - .format("The injected field %s expects a mocked bean; but obtained null", field)); - } else if (handle.getBean().getKind() != io.quarkus.arc.InjectableBean.Kind.SYNTHETIC) { + .format("The injected field [%s] expects a mocked bean; but obtained null", field)); + } + if (handle.getBean().getKind() != io.quarkus.arc.InjectableBean.Kind.SYNTHETIC) { throw new IllegalStateException(String - .format("The injected field %s expects a mocked bean; but obtained: %s", field, + .format("The injected field [%s] expects a mocked bean; but obtained: %s", field, handle.getBean())); } } From f3a1a5175d311d057f9d3c7018079271b23c2ed4 Mon Sep 17 00:00:00 2001 From: Peter Palaga Date: Wed, 12 Jun 2024 15:47:43 +0200 Subject: [PATCH 6/6] Fixup #41085 Docker-build fails to detect podman: add back detectContainerRuntime() method for backwards compatibility (cherry picked from commit 7c176b698e1c86228ebd08de2c248d2c2dcf90d0) --- .../java/io/quarkus/deployment/util/ContainerRuntimeUtil.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/core/deployment/src/main/java/io/quarkus/deployment/util/ContainerRuntimeUtil.java b/core/deployment/src/main/java/io/quarkus/deployment/util/ContainerRuntimeUtil.java index 97133d8b2a899..5cf44bc7adb33 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/util/ContainerRuntimeUtil.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/util/ContainerRuntimeUtil.java @@ -43,6 +43,10 @@ private ContainerRuntimeUtil() { * @return a fully resolved {@link ContainerRuntime} indicating if Docker or Podman is available and in rootless mode or not * @throws IllegalStateException if no container runtime was found to build the image */ + public static ContainerRuntime detectContainerRuntime() { + return detectContainerRuntime(true); + } + public static ContainerRuntime detectContainerRuntime(ContainerRuntime... orderToCheckRuntimes) { return detectContainerRuntime(true, orderToCheckRuntimes); }