From 8c0ddeee8e4f8edbbf8e180e47636d30a47fb199 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Vav=C5=99=C3=ADk?= Date: Tue, 21 Jan 2025 17:15:36 +0100 Subject: [PATCH] Support permission checkers for WebSockets Next --- .../asciidoc/websockets-next-reference.adoc | 177 ++++++++++--- ...usPermissionSecurityIdentityAugmentor.java | 133 ++++++++-- ...issionsAllowedMetaAnnotationBuildItem.java | 2 +- .../runtime/security/HttpSecurityUtils.java | 7 +- .../next/deployment/WebSocketProcessor.java | 103 ++++++-- ...ssionCheckerArgsValidationFailureTest.java | 60 +++++ .../HttpUpgradePermissionCheckerTest.java | 235 +++++++++++++++++ .../PayloadPermissionCheckerTest.java | 245 ++++++++++++++++++ .../next/test/security/ProductEndpoint.java | 48 ++++ .../websockets/next/runtime/Endpoints.java | 13 +- .../it/resteasy/elytron/RootResource.java | 2 + 11 files changed, 942 insertions(+), 83 deletions(-) create mode 100644 extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/security/HttpUpgradePermissionCheckerArgsValidationFailureTest.java create mode 100644 extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/security/HttpUpgradePermissionCheckerTest.java create mode 100644 extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/security/PayloadPermissionCheckerTest.java create mode 100644 extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/security/ProductEndpoint.java diff --git a/docs/src/main/asciidoc/websockets-next-reference.adoc b/docs/src/main/asciidoc/websockets-next-reference.adoc index aa30b17a835720..ddb701e4a39632 100644 --- a/docs/src/main/asciidoc/websockets-next-reference.adoc +++ b/docs/src/main/asciidoc/websockets-next-reference.adoc @@ -185,8 +185,12 @@ public class MyWebSocket { ==== Request context -If an endpoint is annotated with `@RequestScoped`, or with a security annotation (such as `@RolesAllowed`), or depends directly or indirectly on a `@RequestScoped` bean, or on a bean annotated with a security annotation, then each WebSocket endpoint callback method execution is associated with a new _request context_. -The request context is active during endpoint callback invocation. +Each WebSocket endpoint callback method execution is associated with a new CDI _request context_ if the endpoint is: + +* Annotated with the `@RequestScoped` annotation. +* Has a method annotated with a security annotation such as `@RolesAllowed`. +* Depends directly or indirectly on a `@RequestScoped` bean. +* Depends directly or indirectly on a CDI beans secured with a standard security annotation. TIP: It is also possible to set the `quarkus.websockets-next.server.activate-request-context` config property to `always`. In this case, the request context is always activated when an endpoint callback is invoked. @@ -783,6 +787,67 @@ class MyBean { [[websocket-next-security]] === Security +Security capabilities are provided by the Quarkus Security extension. +Any xref:security-identity-providers.adoc[Identity provider] can be used to convert authentication credentials on the initial HTTP request into a `SecurityIdentity` instance. +The `SecurityIdentity` is then associated with the websocket connection. +Authorization options are demonstrated in following sections. + +NOTE: When an OpenID Connect extension, `quarkus-oidc`, is used and token expires, Quarkus automatically closes connection. + +[[secure-http-upgrade]] +==== Secure HTTP upgrade + +An HTTP upgrade is secured when a standard security annotation is placed on an endpoint class or an HTTP Security policy is defined. +The advantage of securing HTTP upgrade is less processing, the authorization is performed early and only once. +You should always prefer HTTP upgrade security unless, like in the example above, you need to perform an action on error or a security check based on the payload. + +.Use standard security annotation to secure an HTTP upgrade +[source, java] +---- +package io.quarkus.websockets.next.test.security; + +import io.quarkus.security.Authenticated; +import jakarta.inject.Inject; + +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.websockets.next.OnOpen; +import io.quarkus.websockets.next.OnTextMessage; +import io.quarkus.websockets.next.WebSocket; + +@Authenticated <1> +@WebSocket(path = "/end") +public class Endpoint { + + @Inject + SecurityIdentity currentIdentity; + + @OnOpen + String open() { + return "ready"; + } + + @OnTextMessage + String echo(String message) { + return message; + } +} +---- +<1> Initial HTTP handshake ends with the 401 status for anonymous users. +You can also redirect the handshake request on authorization failure with the `quarkus.websockets-next.server.security.auth-failure-redirect-url` configuration property. + +IMPORTANT: HTTP upgrade is only secured when a security annotation is declared on an endpoint class next to the `@WebSocket` annotation. +Placing a security annotation on an endpoint bean will not secure bean methods, only the HTTP upgrade. +You must always verify that your endpoint is secured as intended. + +.Use HTTP Security policy to secure an HTTP upgrade +[source,properties] +---- +quarkus.http.auth.permission.http-upgrade.paths=/end +quarkus.http.auth.permission.http-upgrade.policy=authenticated +---- + +==== Secure WebSocket endpoint callback methods + WebSocket endpoint callback methods can be secured with security annotations such as `io.quarkus.security.Authenticated`, `jakarta.annotation.security.RolesAllowed` and other annotations listed in the xref:security-authorize-web-endpoints-reference.adoc#standard-security-annotations[Supported security annotations] documentation. @@ -828,60 +893,108 @@ public class Endpoint { <1> The echo callback method can only be invoked if the current security identity has an `admin` role. <2> The error handler is invoked in case of the authorization failure. -`SecurityIdentity` is initially created during a secure HTTP upgrade and associated with the websocket connection. +==== Secure server endpoints with permission checkers -NOTE: When OpenID Connect extension is used and token expires, Quarkus automatically closes connection. +WebSocket endpoints can be secured with the xref:security-authorize-web-endpoints-reference.adoc#permission-checker[permission checkers]. +We recommend to <> rather than individual endpoint methods. For example: -=== Secure HTTP upgrade +.Example of a WebSocket endpoint with secured HTTP upgrade +[source, java] +---- +package io.quarkus.websockets.next.test.security; -An HTTP upgrade is secured when standard security annotation is placed on an endpoint class or an HTTP Security policy is defined. -The advantage of securing HTTP upgrade is less processing, the authorization is performed early and only once. -You should always prefer HTTP upgrade security unless, like in th example above, you need to perform action on error. +import io.quarkus.security.PermissionsAllowed; +import io.quarkus.websockets.next.OnTextMessage; +import io.quarkus.websockets.next.WebSocket; -.Use standard security annotation to secure an HTTP upgrade +@PermissionsAllowed("product:premium") +@WebSocket(path = "/product/premium") +public class PremiumProductEndpoint { + + @OnTextMessage + PremiumProduct getPremiumProduct(int productId) { + return new PremiumProduct(productId); + } + +} +---- + +.Example of a permission checker authorizing the HTTP upgrade [source, java] ---- package io.quarkus.websockets.next.test.security; -import io.quarkus.security.Authenticated; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.PermissionChecker; +import io.quarkus.vertx.http.runtime.security.HttpSecurityUtils; +import io.vertx.ext.web.RoutingContext; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class PermissionChecker { + + @PermissionChecker("product:premium") + public boolean canGetPremiumProduct(SecurityIdentity securityIdentity) { <1> + String username = currentIdentity.getPrincipal().getName(); + + RoutingContext routingContext = HttpSecurityUtils.getRoutingContextAttribute(securityIdentity); + String initialHttpUpgradePath = routingContext == null ? null : routingContext.normalizedPath(); + if (!isUserAllowedToAccessPath(initialHttpUpgradePath, username)) { + return false; + } + + return isPremiumCustomer(username); + } + +} +---- +<1> A permission checker authorizing an HTTP upgrade must declare exactly one method parameter, the `SecurityIdentity`. + +It is also possible to run security checks on every message. For example, a message payload can be accessed like this: + +[source, java] +---- +package io.quarkus.websockets.next.test.security; + +import io.quarkus.security.PermissionChecker; +import io.quarkus.security.PermissionsAllowed; import jakarta.inject.Inject; +import io.quarkus.security.ForbiddenException; import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.websockets.next.OnError; import io.quarkus.websockets.next.OnOpen; import io.quarkus.websockets.next.OnTextMessage; import io.quarkus.websockets.next.WebSocket; -@Authenticated <1> -@WebSocket(path = "/end") -public class Endpoint { +@WebSocket(path = "/product") +public class ProductEndpoint { + + private record Product(int id, String name) {} @Inject SecurityIdentity currentIdentity; - @OnOpen - String open() { - return "ready"; - } - + @PermissionsAllowed("product:get") @OnTextMessage - String echo(String message) { - return message; + Product getProduct(int productId) { <1> + return new Product(productId, "Product " + productId); } -} ----- -<1> Initial HTTP handshake ends with the 401 status for anonymous users. -You can also redirect the handshake request on authorization failure with the `quarkus.websockets-next.server.security.auth-failure-redirect-url` configuration property. -IMPORTANT: HTTP upgrade is only secured when a security annotation is declared on an endpoint class next to the `@WebSocket` annotation. -Placing a security annotation on an endpoint bean will not secure bean methods, only the HTTP upgrade. -You must always verify that your endpoint is secured as intended. + @OnError + String error(ForbiddenException t) { <2> + return "forbidden:" + currentIdentity.getPrincipal().getName(); + } -.Use HTTP Security policy to secure an HTTP upgrade -[source,properties] ----- -quarkus.http.auth.permission.http-upgrade.paths=/end -quarkus.http.auth.permission.http-upgrade.policy=authenticated + @PermissionChecker("product:get") + boolean canGetProduct(int productId) { + String username = currentIdentity.getPrincipal().getName(); + return currentIdentity.hasRole("admin") || canUserGetProduct(productId, username); + } +} ---- +<1> The `getProduct` callback method can only be invoked if the current security identity has an `admin` role or the user is allowed to get the product detail. +<2> The error handler is invoked in case of the authorization failure. === Inspect and/or reject HTTP upgrade diff --git a/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/QuarkusPermissionSecurityIdentityAugmentor.java b/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/QuarkusPermissionSecurityIdentityAugmentor.java index 6557b84a3b1fc3..2a276668f87c61 100644 --- a/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/QuarkusPermissionSecurityIdentityAugmentor.java +++ b/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/QuarkusPermissionSecurityIdentityAugmentor.java @@ -1,10 +1,16 @@ package io.quarkus.security.runtime; import java.security.Permission; +import java.security.Principal; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.function.BiFunction; import java.util.function.Function; import java.util.function.Predicate; import io.quarkus.security.ForbiddenException; +import io.quarkus.security.credential.Credential; import io.quarkus.security.identity.AuthenticationRequestContext; import io.quarkus.security.identity.SecurityIdentity; import io.quarkus.security.identity.SecurityIdentityAugmentor; @@ -33,33 +39,39 @@ public Throwable apply(Throwable throwable) { return new ForbiddenException(throwable); } }; + // keep in sync with HttpSecurityUtils + private static final String ROUTING_CONTEXT_ATTRIBUTE = "quarkus.http.routing.context"; - private final BlockingSecurityExecutor blockingExecutor; + private final BiFunction> permissionChecker; QuarkusPermissionSecurityIdentityAugmentor(BlockingSecurityExecutor blockingExecutor) { - this.blockingExecutor = blockingExecutor; + this.permissionChecker = new BiFunction>() { + @Override + public Uni apply(SecurityIdentity finalIdentity, Permission requiredpermission) { + if (requiredpermission instanceof QuarkusPermission quarkusPermission) { + return quarkusPermission + .isGranted(finalIdentity, blockingExecutor) + .onFailure(NOT_A_FORBIDDEN_EXCEPTION).transform(WRAP_WITH_FORBIDDEN_EXCEPTION); + } + return Uni.createFrom().item(false); + } + }; } @Override - public Uni augment(SecurityIdentity identity, AuthenticationRequestContext context) { + public Uni augment(SecurityIdentity identity, AuthenticationRequestContext context, + Map attributes) { if (identity.isAnonymous()) { return Uni.createFrom().item(identity); } - return Uni.createFrom().item(QuarkusSecurityIdentity - .builder(identity) - .addPermissionChecker(new Function<>() { - @Override - public Uni apply(Permission requiredpermission) { - if (requiredpermission instanceof QuarkusPermission quarkusPermission) { - return quarkusPermission - .isGranted(identity, blockingExecutor) - .onFailure(NOT_A_FORBIDDEN_EXCEPTION).transform(WRAP_WITH_FORBIDDEN_EXCEPTION); - } - return Uni.createFrom().item(false); - } - }) - .build()); + return Uni.createFrom().item( + new PermissionCheckerIdentityDecorator(identity, attributes.get(ROUTING_CONTEXT_ATTRIBUTE), permissionChecker)); + } + + @Override + public Uni augment(SecurityIdentity identity, AuthenticationRequestContext context) { + return augment(identity, context, Map.of()); } @Override @@ -67,4 +79,91 @@ public int priority() { // we do not rely on this value and always add this augmentor as the last one manually return Integer.MAX_VALUE; } + + private static final class PermissionCheckerIdentityDecorator implements SecurityIdentity { + + private final SecurityIdentity delegate; + private final Object routingContext; + private final BiFunction> permissionChecker; + + private PermissionCheckerIdentityDecorator(SecurityIdentity delegate, Object routingContext, + BiFunction> permissionChecker) { + this.delegate = delegate; + this.routingContext = routingContext; + this.permissionChecker = permissionChecker; + } + + @Override + public Principal getPrincipal() { + return delegate.getPrincipal(); + } + + @Override + public T getPrincipal(Class clazz) { + return delegate.getPrincipal(clazz); + } + + @Override + public boolean isAnonymous() { + return false; + } + + @Override + public Set getRoles() { + return delegate.getRoles(); + } + + @Override + public boolean hasRole(String s) { + return delegate.hasRole(s); + } + + @Override + public T getCredential(Class aClass) { + return delegate.getCredential(aClass); + } + + @Override + public Set getCredentials() { + return delegate.getCredentials(); + } + + @SuppressWarnings("unchecked") + @Override + public T getAttribute(String s) { + if (ROUTING_CONTEXT_ATTRIBUTE.equals(s)) { + return (T) routingContext; + } + return delegate.getAttribute(s); + } + + @Override + public Map getAttributes() { + if (routingContext != null) { + Map attributes = new HashMap<>(); + attributes.put(ROUTING_CONTEXT_ATTRIBUTE, routingContext); + return attributes; + } + return delegate.getAttributes(); + } + + @Override + public Uni checkPermission(Permission permission) { + return permissionChecker.apply(this, permission) + .flatMap(new Function>() { + @Override + public Uni apply(Boolean accessGranted) { + if (Boolean.TRUE.equals(accessGranted)) { + return Uni.createFrom().item(true); + } + return delegate.checkPermission(permission); + } + }); + } + + @Override + public boolean checkPermissionBlocking(Permission permission) { + return checkPermission(permission).await().indefinitely(); + } + } } diff --git a/extensions/security/spi/src/main/java/io/quarkus/security/spi/PermissionsAllowedMetaAnnotationBuildItem.java b/extensions/security/spi/src/main/java/io/quarkus/security/spi/PermissionsAllowedMetaAnnotationBuildItem.java index cfefe7aaa9cf33..764863db43ab5a 100644 --- a/extensions/security/spi/src/main/java/io/quarkus/security/spi/PermissionsAllowedMetaAnnotationBuildItem.java +++ b/extensions/security/spi/src/main/java/io/quarkus/security/spi/PermissionsAllowedMetaAnnotationBuildItem.java @@ -49,7 +49,7 @@ public List getTransitiveInstances() { return transitiveInstances; } - private boolean hasPermissionsAllowed(List instances) { + public boolean hasPermissionsAllowed(List instances) { return instances.stream().anyMatch(ai -> metaAnnotationNames.contains(ai.name())); } diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityUtils.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityUtils.java index c310d2efa0022f..a364d81c16d9fa 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityUtils.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityUtils.java @@ -12,6 +12,7 @@ import io.vertx.ext.web.RoutingContext; public final class HttpSecurityUtils { + // keep in sync with QuarkusPermissionSecurityIdentityAugmentor public final static String ROUTING_CONTEXT_ATTRIBUTE = "quarkus.http.routing.context"; static final String SECURITY_IDENTITIES_ATTRIBUTE = "io.quarkus.security.identities"; static final String COMMON_NAME = "CN"; @@ -55,7 +56,11 @@ public static RoutingContext getRoutingContextAttribute(AuthenticationRequest re } public static RoutingContext getRoutingContextAttribute(SecurityIdentity identity) { - return identity.getAttribute(RoutingContext.class.getName()); + RoutingContext routingContext = identity.getAttribute(RoutingContext.class.getName()); + if (routingContext != null) { + return routingContext; + } + return identity.getAttribute(ROUTING_CONTEXT_ATTRIBUTE); } public static RoutingContext getRoutingContextAttribute(Map authenticationRequestAttributes) { diff --git a/extensions/websockets-next/deployment/src/main/java/io/quarkus/websockets/next/deployment/WebSocketProcessor.java b/extensions/websockets-next/deployment/src/main/java/io/quarkus/websockets/next/deployment/WebSocketProcessor.java index bd5305bc8bb1ef..c0ae258242cf23 100644 --- a/extensions/websockets-next/deployment/src/main/java/io/quarkus/websockets/next/deployment/WebSocketProcessor.java +++ b/extensions/websockets-next/deployment/src/main/java/io/quarkus/websockets/next/deployment/WebSocketProcessor.java @@ -1,7 +1,6 @@ package io.quarkus.websockets.next.deployment; import static io.quarkus.deployment.annotations.ExecutionTime.RUNTIME_INIT; -import static io.quarkus.security.spi.SecurityTransformerUtils.hasSecurityAnnotation; import java.util.ArrayList; import java.util.Comparator; @@ -25,6 +24,7 @@ import jakarta.inject.Singleton; import org.jboss.jandex.AnnotationInstance; +import org.jboss.jandex.AnnotationTarget; import org.jboss.jandex.AnnotationTransformation; import org.jboss.jandex.AnnotationValue; import org.jboss.jandex.ClassInfo; @@ -65,6 +65,7 @@ import io.quarkus.arc.processor.ScopeInfo; import io.quarkus.arc.processor.Types; import io.quarkus.bootstrap.classloading.QuarkusClassLoader; +import io.quarkus.builder.item.SimpleBuildItem; import io.quarkus.deployment.Capabilities; import io.quarkus.deployment.Capability; import io.quarkus.deployment.GeneratedClassGizmoAdaptor; @@ -92,6 +93,7 @@ import io.quarkus.runtime.metrics.MetricsFactory; import io.quarkus.security.spi.ClassSecurityCheckAnnotationBuildItem; import io.quarkus.security.spi.ClassSecurityCheckStorageBuildItem; +import io.quarkus.security.spi.PermissionsAllowedMetaAnnotationBuildItem; import io.quarkus.security.spi.SecurityTransformerUtils; import io.quarkus.security.spi.runtime.SecurityCheck; import io.quarkus.vertx.http.deployment.RouteBuildItem; @@ -453,30 +455,38 @@ public String apply(String name) { @BuildStep public void registerRoutes(WebSocketServerRecorder recorder, List endpoints, List generatedEndpoints, WebSocketsServerBuildConfig config, - ValidationPhaseBuildItem validationPhase, BuildProducer routes) { + ValidationPhaseBuildItem validationPhase, BuildProducer routes, + Optional metaPermissionsAllowed, + EndpointSecurityChecksBuildItem endpointSecurityChecks, Capabilities capabilities) { + boolean securityEnabled = capabilities.isPresent(Capability.SECURITY); for (GeneratedEndpointBuildItem endpoint : generatedEndpoints.stream().filter(GeneratedEndpointBuildItem::isServer) .toList()) { + boolean httpUpgradeSecured = endpointSecurityChecks.endpointIdToSecurityCheck.containsKey(endpoint.endpointId); RouteBuildItem.Builder builder = RouteBuildItem.builder() .route(endpoint.path) .displayOnNotFoundPage("WebSocket Endpoint") .handlerType(HandlerType.NORMAL) .handler(recorder.createEndpointHandler(endpoint.generatedClassName, endpoint.endpointId, activateContext(config.activateRequestContext(), BuiltinScope.REQUEST.getInfo(), - endpoint.endpointId, endpoints, validationPhase.getBeanResolver()), + endpoint.endpointId, endpoints, validationPhase.getBeanResolver(), metaPermissionsAllowed, + securityEnabled, httpUpgradeSecured), activateContext(config.activateSessionContext(), new ScopeInfo(DotName.createSimple(SessionScoped.class), true), endpoint.endpointId, - endpoints, validationPhase.getBeanResolver()), + endpoints, validationPhase.getBeanResolver(), metaPermissionsAllowed, securityEnabled, + httpUpgradeSecured), endpoint.path)); routes.produce(builder.build()); } } private boolean activateContext(WebSocketsServerBuildConfig.ContextActivation activation, ScopeInfo scope, - String endpointId, - List endpoints, BeanResolver beanResolver) { + String endpointId, List endpoints, BeanResolver beanResolver, + Optional metaPermissionsAllowed, + boolean securityEnabled, boolean httpUpgradeSecured) { return switch (activation) { case ALWAYS -> true; - case AUTO -> needsContext(findEndpoint(endpointId, endpoints).bean, scope, new HashSet<>(), beanResolver); + case AUTO -> needsContext(findEndpoint(endpointId, endpoints).bean, scope, new HashSet<>(), beanResolver, + metaPermissionsAllowed, securityEnabled, httpUpgradeSecured); default -> throw new IllegalArgumentException("Unexpected value: " + activation); }; } @@ -490,23 +500,27 @@ private WebSocketEndpointBuildItem findEndpoint(String endpointId, List processedBeans, BeanResolver beanResolver) { + private boolean needsContext(BeanInfo bean, ScopeInfo scope, Set processedBeans, BeanResolver beanResolver, + Optional metaPermissionsAllowed, boolean securityEnabled, + boolean httpUpgradeSecured) { if (processedBeans.add(bean.getIdentifier())) { if (scope.equals(bean.getScope())) { // Bean has the given scope return true; - } else if (BuiltinScope.REQUEST.is(scope) + } else if (securityEnabled && BuiltinScope.REQUEST.is(scope) && bean.isClassBean() && bean.hasAroundInvokeInterceptors() - && SecurityTransformerUtils.hasSecurityAnnotation(bean.getTarget().get().asClass())) { + && hasSecurityAnnNotOnHttpUpgrade(bean.getTarget().get().asClass(), metaPermissionsAllowed, + httpUpgradeSecured)) { // The given scope is RequestScoped, the bean is class-based, has an aroundInvoke interceptor associated and is annotated with a security annotation return true; } for (InjectionPointInfo injectionPoint : bean.getAllInjectionPoints()) { BeanInfo dependency = injectionPoint.getResolvedBean(); if (dependency != null) { - if (needsContext(dependency, scope, processedBeans, beanResolver)) { + if (needsContext(dependency, scope, processedBeans, beanResolver, metaPermissionsAllowed, securityEnabled, + false)) { return true; } } else { @@ -527,7 +541,8 @@ private boolean needsContext(BeanInfo bean, ScopeInfo scope, Set process if (requiredType != null) { // For programmatic lookup and @All List<> we need to resolve the beans manually for (BeanInfo lookupDependency : beanResolver.resolveBeans(requiredType, qualifiers)) { - if (needsContext(lookupDependency, scope, processedBeans, beanResolver)) { + if (needsContext(lookupDependency, scope, processedBeans, beanResolver, metaPermissionsAllowed, + securityEnabled, false)) { return true; } } @@ -538,6 +553,21 @@ private boolean needsContext(BeanInfo bean, ScopeInfo scope, Set process return false; } + private static boolean hasSecurityAnnNotOnHttpUpgrade(ClassInfo classInfo, + Optional metaPermissionsAllowed, boolean httpUpgradeSecured) { + final List annotations; + if (httpUpgradeSecured) { + // this is endpoint class and HTTP upgrade is secured, so we only need active CDI request context for methods + annotations = classInfo.annotations().stream() + .filter(ai -> ai.target() != null && ai.target().kind() == AnnotationTarget.Kind.METHOD).toList(); + } else { + // class and method annotations + annotations = classInfo.annotations(); + } + return SecurityTransformerUtils.hasSecurityAnnotation(annotations) + || metaPermissionsAllowed.get().hasPermissionsAllowed(annotations); + } + @BuildStep UnremovableBeanBuildItem makeHttpUpgradeChecksUnremovable() { // we access the checks programmatically @@ -626,23 +656,32 @@ void preventRepeatedSecurityChecksForHttpUpgrade(Capabilities capabilities, } } - @Record(RUNTIME_INIT) @BuildStep - void createSecurityHttpUpgradeCheck(Capabilities capabilities, BuildProducer producer, - Optional storageItem, - BeanArchiveIndexBuildItem indexItem, - WebSocketServerRecorder recorder, List endpoints) { + EndpointSecurityChecksBuildItem collectEndpointSecurityChecks(BeanArchiveIndexBuildItem indexItem, + List endpoints, Optional storageItem, + Capabilities capabilities) { + final Map endpointIdToSecurityCheck; if (capabilities.isPresent(Capability.SECURITY) && storageItem.isPresent()) { - var endpointIdToSecurityCheck = collectEndpointSecurityChecks(endpoints, storageItem.get(), indexItem.getIndex()); - if (!endpointIdToSecurityCheck.isEmpty()) { - producer.produce(SyntheticBeanBuildItem - .configure(HttpUpgradeCheck.class) - .scope(BuiltinScope.SINGLETON.getInfo()) - .priority(SecurityHttpUpgradeCheck.BEAN_PRIORITY) - .setRuntimeInit() - .supplier(recorder.createSecurityHttpUpgradeCheck(endpointIdToSecurityCheck)) - .done()); - } + endpointIdToSecurityCheck = collectEndpointSecurityChecks(endpoints, storageItem.get(), indexItem.getIndex()); + } else { + endpointIdToSecurityCheck = Map.of(); + } + return new EndpointSecurityChecksBuildItem(endpointIdToSecurityCheck); + } + + @Record(RUNTIME_INIT) + @BuildStep + void createSecurityHttpUpgradeCheck(BuildProducer producer, + EndpointSecurityChecksBuildItem endpointSecurityChecks, WebSocketServerRecorder recorder) { + var endpointIdToSecurityCheck = endpointSecurityChecks.endpointIdToSecurityCheck; + if (!endpointIdToSecurityCheck.isEmpty()) { + producer.produce(SyntheticBeanBuildItem + .configure(HttpUpgradeCheck.class) + .scope(BuiltinScope.SINGLETON.getInfo()) + .priority(SecurityHttpUpgradeCheck.BEAN_PRIORITY) + .setRuntimeInit() + .supplier(recorder.createSecurityHttpUpgradeCheck(endpointIdToSecurityCheck)) + .done()); } } @@ -698,7 +737,7 @@ private static Map collectEndpointSecurityChecks(List endpointIdToSecurityCheck; + + private EndpointSecurityChecksBuildItem(Map endpointIdToSecurityCheck) { + this.endpointIdToSecurityCheck = endpointIdToSecurityCheck; + } + } } diff --git a/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/security/HttpUpgradePermissionCheckerArgsValidationFailureTest.java b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/security/HttpUpgradePermissionCheckerArgsValidationFailureTest.java new file mode 100644 index 00000000000000..de70b09cd9ad70 --- /dev/null +++ b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/security/HttpUpgradePermissionCheckerArgsValidationFailureTest.java @@ -0,0 +1,60 @@ +package io.quarkus.websockets.next.test.security; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import jakarta.enterprise.context.ApplicationScoped; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.security.PermissionChecker; +import io.quarkus.security.PermissionsAllowed; +import io.quarkus.security.test.utils.TestIdentityController; +import io.quarkus.security.test.utils.TestIdentityProvider; +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.websockets.next.OnTextMessage; +import io.quarkus.websockets.next.WebSocket; +import io.quarkus.websockets.next.test.utils.WSClient; + +public class HttpUpgradePermissionCheckerArgsValidationFailureTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(Endpoint.class, WSClient.class, TestIdentityProvider.class, TestIdentityController.class, + Checker.class)) + .assertException(t -> { + assertInstanceOf(IllegalArgumentException.class, t); + assertTrue(t.getMessage() + .contains("@PermissionAllowed instance that accepts method arguments must be placed on a method")); + }); + + @Test + public void test() { + Assertions.fail(); + } + + @PermissionsAllowed("echo") + @WebSocket(path = "/endpoint") + public static class Endpoint { + + @OnTextMessage + String echo(String message) { + return message; + } + + } + + @ApplicationScoped + public static class Checker { + + @PermissionChecker("echo") + boolean canEcho(String message) { + return true; + } + + } + +} diff --git a/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/security/HttpUpgradePermissionCheckerTest.java b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/security/HttpUpgradePermissionCheckerTest.java new file mode 100644 index 00000000000000..39048e680331f3 --- /dev/null +++ b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/security/HttpUpgradePermissionCheckerTest.java @@ -0,0 +1,235 @@ +package io.quarkus.websockets.next.test.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.util.concurrent.CompletionException; +import java.util.stream.Stream; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.logging.Log; +import io.quarkus.runtime.util.ExceptionUtil; +import io.quarkus.security.ForbiddenException; +import io.quarkus.security.PermissionChecker; +import io.quarkus.security.PermissionsAllowed; +import io.quarkus.security.identity.CurrentIdentityAssociation; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.test.utils.TestIdentityController; +import io.quarkus.security.test.utils.TestIdentityProvider; +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; +import io.quarkus.vertx.http.runtime.security.HttpSecurityUtils; +import io.quarkus.websockets.next.OnError; +import io.quarkus.websockets.next.OnOpen; +import io.quarkus.websockets.next.OnTextMessage; +import io.quarkus.websockets.next.WebSocket; +import io.quarkus.websockets.next.test.utils.WSClient; +import io.vertx.core.http.UpgradeRejectedException; + +public class HttpUpgradePermissionCheckerTest extends SecurityTestBase { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(Endpoint.class, WSClient.class, TestIdentityProvider.class, TestIdentityController.class, + AdminEndpoint.class, InclusiveEndpoint.class, MetaAnnotationEndpoint.class, + StringEndpointReadPermissionMetaAnnotation.class)); + + @TestHTTPResource("admin-end") + URI adminEndpointUri; + + @TestHTTPResource("meta-annotation") + URI metaAnnotationEndpointUri; + + @TestHTTPResource("inclusive-end") + URI inclusiveEndpointUri; + + @BeforeEach + public void prepareUsers() { + TestIdentityController.resetRoles().add("admin", "admin").add("almighty", "almighty").add("user", "user"); + } + + @Test + public void testInsufficientRights() { + try (WSClient client = new WSClient(vertx)) { + CompletionException ce = assertThrows(CompletionException.class, + () -> client.connect(basicAuth("user", "user"), adminEndpointUri)); + Throwable root = ExceptionUtil.getRootCause(ce); + assertInstanceOf(UpgradeRejectedException.class, root); + assertTrue(root.getMessage().contains("403")); + } + try (WSClient client = new WSClient(vertx)) { + client.connect(basicAuth("admin", "admin"), adminEndpointUri); + client.waitForMessages(1); + assertEquals("ready", client.getMessages().get(0).toString()); + client.sendAndAwait("hello"); + client.waitForMessages(2); + assertEquals("hello", client.getMessages().get(1).toString()); + } + } + + @Test + public void testMetaAnnotation() { + try (WSClient client = new WSClient(vertx)) { + CompletionException ce = assertThrows(CompletionException.class, + () -> client.connect(basicAuth("user", "user"), metaAnnotationEndpointUri)); + Throwable root = ExceptionUtil.getRootCause(ce); + assertInstanceOf(UpgradeRejectedException.class, root); + assertTrue(root.getMessage().contains("403")); + } + try (WSClient client = new WSClient(vertx)) { + client.connect(basicAuth("admin", "admin"), metaAnnotationEndpointUri); + client.waitForMessages(1); + assertEquals("ready", client.getMessages().get(0).toString()); + client.sendAndAwait("hello"); + client.waitForMessages(2); + assertEquals("hello", client.getMessages().get(1).toString()); + } + } + + @Test + public void testInclusivePermissions() { + Stream.of("admin", "user").forEach(name -> { + try (WSClient client = new WSClient(vertx)) { + CompletionException ce = assertThrows(CompletionException.class, + () -> client.connect(basicAuth(name, name), inclusiveEndpointUri)); + Throwable root = ExceptionUtil.getRootCause(ce); + assertInstanceOf(UpgradeRejectedException.class, root); + assertTrue(root.getMessage().contains("403")); + } + }); + try (WSClient client = new WSClient(vertx)) { + client.connect(basicAuth("almighty", "almighty"), inclusiveEndpointUri); + client.waitForMessages(1); + assertEquals("ready", client.getMessages().get(0).toString()); + client.sendAndAwait("hello"); + client.waitForMessages(2); + assertEquals("hello", client.getMessages().get(1).toString()); + } + } + + @PermissionsAllowed(value = { "perm1", "perm2" }, inclusive = true) + @WebSocket(path = "/inclusive-end") + public static class InclusiveEndpoint { + + @OnOpen + String open() { + return "ready"; + } + + @OnTextMessage + String echo(String message) { + return message; + } + + } + + @PermissionsAllowed("endpoint:read") + @WebSocket(path = "/admin-end") + public static class AdminEndpoint { + + @OnOpen + String open() { + return "ready"; + } + + @OnTextMessage + String echo(String message) { + return message; + } + + } + + @StringEndpointReadPermissionMetaAnnotation + @WebSocket(path = "/meta-annotation") + public static class MetaAnnotationEndpoint { + + @OnOpen + String open() { + return "ready"; + } + + @OnTextMessage + String echo(String message) { + return message; + } + + } + + @PermissionsAllowed(value = { "endpoint:connect", "endpoint:read" }) + @WebSocket(path = "/end") + public static class Endpoint { + + @Inject + CurrentIdentityAssociation currentIdentity; + + @OnOpen + String open() { + return "ready"; + } + + @PermissionsAllowed("endpoint:read") + @OnTextMessage + String echo(String message) { + return message; + } + + @OnError + String error(ForbiddenException t) { + return "forbidden:" + currentIdentity.getIdentity().getPrincipal().getName(); + } + + } + + @ApplicationScoped + public static class Checker { + + @PermissionChecker("endpoint:connect") + boolean canConnectToEndpoint(SecurityIdentity securityIdentity) { + if (HttpSecurityUtils.getRoutingContextAttribute(securityIdentity) == null) { + Log.error("Routing context not found, denying access"); + return false; + } + return securityIdentity.getPrincipal().getName().equals("user"); + } + + @PermissionChecker("endpoint:read") + boolean canDoReadOnEndpoint(SecurityIdentity securityIdentity) { + if (HttpSecurityUtils.getRoutingContextAttribute(securityIdentity) == null) { + Log.error("Routing context not found, denying access"); + return false; + } + return securityIdentity.getPrincipal().getName().equals("admin"); + } + + @PermissionChecker("perm1") + boolean hasPerm1(SecurityIdentity securityIdentity) { + if (HttpSecurityUtils.getRoutingContextAttribute(securityIdentity) == null) { + Log.error("Routing context not found, denying access"); + return false; + } + String principalName = securityIdentity.getPrincipal().getName(); + return principalName.equals("admin") || principalName.equals("almighty"); + } + + @PermissionChecker("perm2") + boolean hasPerm2(SecurityIdentity securityIdentity) { + if (HttpSecurityUtils.getRoutingContextAttribute(securityIdentity) == null) { + Log.error("Routing context not found, denying access"); + return false; + } + String principalName = securityIdentity.getPrincipal().getName(); + return principalName.equals("user") || principalName.equals("almighty"); + } + + } +} diff --git a/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/security/PayloadPermissionCheckerTest.java b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/security/PayloadPermissionCheckerTest.java new file mode 100644 index 00000000000000..473f52cd17c810 --- /dev/null +++ b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/security/PayloadPermissionCheckerTest.java @@ -0,0 +1,245 @@ +package io.quarkus.websockets.next.test.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.util.stream.Stream; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import org.awaitility.Awaitility; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.security.PermissionChecker; +import io.quarkus.security.PermissionsAllowed; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.test.utils.TestIdentityController; +import io.quarkus.security.test.utils.TestIdentityProvider; +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; +import io.quarkus.websockets.next.OnOpen; +import io.quarkus.websockets.next.OnTextMessage; +import io.quarkus.websockets.next.WebSocket; +import io.quarkus.websockets.next.test.utils.WSClient; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpHeaders; +import io.vertx.core.http.WebSocketConnectOptions; +import io.vertx.ext.auth.authentication.UsernamePasswordCredentials; + +public class PayloadPermissionCheckerTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(WSClient.class, TestIdentityProvider.class, TestIdentityController.class, + AdminEndpoint.class, InclusiveEndpoint.class, MetaAnnotationEndpoint.class, + StringEndpointReadPermissionMetaAnnotation.class, ProductEndpoint.class)) + .overrideConfigKey("quarkus.websockets-next.server.unhandled-failure-strategy", "close"); + + @Inject + Vertx vertx; + + @TestHTTPResource("admin-end") + URI adminEndpointUri; + + @TestHTTPResource("meta-annotation") + URI metaAnnotationEndpointUri; + + @TestHTTPResource("inclusive-end") + URI inclusiveEndpointUri; + + @TestHTTPResource("product") + URI productUri; + + @BeforeEach + public void prepareUsers() { + TestIdentityController.resetRoles().add("admin", "admin", "admin").add("almighty", "almighty").add("user", "user"); + } + + @Test + public void testHandledFailure() { + try (WSClient client = new WSClient(vertx)) { + client.connect(basicAuth("user", "user"), productUri); + client.waitForMessages(1); + assertEquals("ready", client.getMessages().get(0).toString()); + client.sendAndAwait("1"); + // shouldn't close as user declared @OnError + client.waitForMessages(2); + // can't see product 1 + assertEquals("forbidden:user", client.getMessages().get(1).toString()); + // can see product 2 + client.sendAndAwait("2"); + client.waitForMessages(3); + String response = client.getMessages().get(2).toString(); + assertTrue(response != null && response.contains("Product 2")); + } + + try (WSClient client = new WSClient(vertx)) { + client.connect(basicAuth("admin", "admin"), productUri); + client.waitForMessages(1); + assertEquals("ready", client.getMessages().get(0).toString()); + // admin can see product 1, unlike the user + client.sendAndAwait("1"); + client.waitForMessages(2); + String response = client.getMessages().get(1).toString(); + assertTrue(response != null && response.contains("Product 1")); + } + } + + @Test + public void testInsufficientRights() { + try (WSClient client = new WSClient(vertx)) { + client.connect(basicAuth("user", "user"), adminEndpointUri); + client.waitForMessages(1); + assertEquals("ready", client.getMessages().get(0).toString()); + client.sendAndAwait("true"); + Awaitility.await().until(client::isClosed); + assertEquals(1008, client.closeStatusCode()); + } + try (WSClient client = new WSClient(vertx)) { + client.connect(basicAuth("admin", "admin"), adminEndpointUri); + client.waitForMessages(1); + assertEquals("ready", client.getMessages().get(0).toString()); + client.sendAndAwait("false"); + Awaitility.await().until(client::isClosed); + assertEquals(1008, client.closeStatusCode()); + } + try (WSClient client = new WSClient(vertx)) { + client.connect(basicAuth("admin", "admin"), adminEndpointUri); + client.waitForMessages(1); + assertEquals("ready", client.getMessages().get(0).toString()); + client.sendAndAwait("true"); + client.waitForMessages(2); + assertEquals("true", client.getMessages().get(1).toString()); + } + } + + @Test + public void testMetaAnnotation() { + try (WSClient client = new WSClient(vertx)) { + client.connect(basicAuth("user", "user"), metaAnnotationEndpointUri); + client.waitForMessages(1); + assertEquals("ready", client.getMessages().get(0).toString()); + client.sendAndAwait("true"); + Awaitility.await().until(client::isClosed); + assertEquals(1008, client.closeStatusCode()); + } + try (WSClient client = new WSClient(vertx)) { + client.connect(basicAuth("admin", "admin"), metaAnnotationEndpointUri); + client.waitForMessages(1); + assertEquals("ready", client.getMessages().get(0).toString()); + client.sendAndAwait("false"); + Awaitility.await().until(client::isClosed); + assertEquals(1008, client.closeStatusCode()); + } + try (WSClient client = new WSClient(vertx)) { + client.connect(basicAuth("admin", "admin"), metaAnnotationEndpointUri); + client.waitForMessages(1); + assertEquals("ready", client.getMessages().get(0).toString()); + client.sendAndAwait("true"); + client.waitForMessages(2); + assertEquals("true", client.getMessages().get(1).toString()); + } + } + + @Test + public void testInclusivePermissions() { + Stream.of("admin", "user").forEach(name -> { + try (WSClient client = new WSClient(vertx)) { + client.connect(basicAuth(name, name), inclusiveEndpointUri); + client.waitForMessages(1); + assertEquals("ready", client.getMessages().get(0).toString()); + client.sendAndAwait("hello"); + Awaitility.await().until(client::isClosed); + assertEquals(1008, client.closeStatusCode()); + } + }); + try (WSClient client = new WSClient(vertx)) { + client.connect(basicAuth("almighty", "almighty"), inclusiveEndpointUri); + client.waitForMessages(1); + assertEquals("ready", client.getMessages().get(0).toString()); + client.sendAndAwait("hello"); + client.waitForMessages(2); + assertEquals("hello", client.getMessages().get(1).toString()); + } + } + + static WebSocketConnectOptions basicAuth(String username, String password) { + return new WebSocketConnectOptions().addHeader(HttpHeaders.AUTHORIZATION.toString(), + new UsernamePasswordCredentials(username, password).applyHttpChallenge(null).toHttpAuthorization()); + } + + @WebSocket(path = "/inclusive-end") + public static class InclusiveEndpoint { + + @OnOpen + String open() { + return "ready"; + } + + @PermissionsAllowed(value = { "perm1", "perm2" }, inclusive = true) + @OnTextMessage + String echo(String message) { + return message; + } + + } + + @WebSocket(path = "/admin-end") + public static class AdminEndpoint { + + @OnOpen + String open() { + return "ready"; + } + + @PermissionsAllowed("endpoint:read") + @OnTextMessage + String echo(boolean canRead) { + return "" + canRead; + } + + } + + @WebSocket(path = "/meta-annotation") + public static class MetaAnnotationEndpoint { + + @OnOpen + String open() { + return "ready"; + } + + @StringEndpointReadPermissionMetaAnnotation + @OnTextMessage + String echo(boolean canRead) { + return "" + canRead; + } + + } + + @ApplicationScoped + public static class Checker { + + @PermissionChecker("endpoint:read") + boolean canDoReadOnEndpoint(SecurityIdentity securityIdentity, boolean canRead) { + return securityIdentity.getPrincipal().getName().equals("admin") && canRead; + } + + @PermissionChecker("perm1") + boolean hasPerm1(SecurityIdentity securityIdentity) { + String principalName = securityIdentity.getPrincipal().getName(); + return principalName.equals("admin") || principalName.equals("almighty"); + } + + @PermissionChecker("perm2") + boolean hasPerm2(SecurityIdentity securityIdentity) { + String principalName = securityIdentity.getPrincipal().getName(); + return principalName.equals("user") || principalName.equals("almighty"); + } + + } +} diff --git a/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/security/ProductEndpoint.java b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/security/ProductEndpoint.java new file mode 100644 index 00000000000000..93e80c33be2860 --- /dev/null +++ b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/security/ProductEndpoint.java @@ -0,0 +1,48 @@ +package io.quarkus.websockets.next.test.security; + +import jakarta.inject.Inject; + +import io.quarkus.security.ForbiddenException; +import io.quarkus.security.PermissionChecker; +import io.quarkus.security.PermissionsAllowed; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.websockets.next.OnError; +import io.quarkus.websockets.next.OnOpen; +import io.quarkus.websockets.next.OnTextMessage; +import io.quarkus.websockets.next.WebSocket; + +@WebSocket(path = "/product") +public class ProductEndpoint { + + private record Product(int id, String name) { + } + + @Inject + SecurityIdentity currentIdentity; + + @OnOpen + String open() { + return "ready"; + } + + @PermissionsAllowed("product:get") + @OnTextMessage + Product getProduct(int productId) { + return new Product(productId, "Product " + productId); + } + + @OnError + String error(ForbiddenException t) { + return "forbidden:" + currentIdentity.getPrincipal().getName(); + } + + @PermissionChecker("product:get") + boolean canGetProduct(int productId) { + String username = currentIdentity.getPrincipal().getName(); + return currentIdentity.hasRole("admin") || canUserGetProduct(productId, username); + } + + private static boolean canUserGetProduct(int productId, String username) { + return productId == 2 && username.equals("user"); + } +} 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 332ca5e0842d7f..60ef3877fc5781 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 @@ -11,7 +11,7 @@ import io.quarkus.arc.InjectableContext; import io.quarkus.arc.ManagedContext; import io.quarkus.runtime.LaunchMode; -import io.quarkus.security.AuthenticationFailedException; +import io.quarkus.security.AuthenticationException; import io.quarkus.security.ForbiddenException; import io.quarkus.security.UnauthorizedException; import io.quarkus.websockets.next.CloseReason; @@ -290,8 +290,13 @@ private static void closeConnection(Throwable cause, String message, WebSocketCo return; } CloseReason closeReason; - int statusCode = connection instanceof WebSocketClientConnectionImpl ? WebSocketCloseStatus.INVALID_MESSAGE_TYPE.code() - : WebSocketCloseStatus.INTERNAL_SERVER_ERROR.code(); + final int statusCode; + if (isSecurityFailure(cause)) { + statusCode = WebSocketCloseStatus.POLICY_VIOLATION.code(); + } else { + statusCode = connection instanceof WebSocketClientConnectionImpl ? WebSocketCloseStatus.INVALID_MESSAGE_TYPE.code() + : WebSocketCloseStatus.INTERNAL_SERVER_ERROR.code(); + } if (LaunchMode.current().isDevOrTest()) { closeReason = new CloseReason(statusCode, cause.getMessage()); } else { @@ -320,7 +325,7 @@ private static void logFailure(Throwable throwable, String message, WebSocketCon private static boolean isSecurityFailure(Throwable throwable) { return throwable instanceof UnauthorizedException - || throwable instanceof AuthenticationFailedException + || throwable instanceof AuthenticationException || throwable instanceof ForbiddenException; } diff --git a/integration-tests/elytron-resteasy/src/main/java/io/quarkus/it/resteasy/elytron/RootResource.java b/integration-tests/elytron-resteasy/src/main/java/io/quarkus/it/resteasy/elytron/RootResource.java index a8ff4305d70c96..32d2adf99c0c81 100644 --- a/integration-tests/elytron-resteasy/src/main/java/io/quarkus/it/resteasy/elytron/RootResource.java +++ b/integration-tests/elytron-resteasy/src/main/java/io/quarkus/it/resteasy/elytron/RootResource.java @@ -19,6 +19,7 @@ import io.quarkus.security.PermissionChecker; import io.quarkus.security.PermissionsAllowed; import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.vertx.http.runtime.security.HttpSecurityUtils; @Path("/") public class RootResource { @@ -72,6 +73,7 @@ public String getAttributes() { } return attributes.entrySet().stream() + .filter(e -> !HttpSecurityUtils.ROUTING_CONTEXT_ATTRIBUTE.equals(e.getKey())) .map(e -> e.getKey() + "=" + e.getValue()) .collect(Collectors.joining(",")); }