Skip to content

Commit 967cc07

Browse files
committed
Support permission checkers for WebSockets Next
1 parent e19bb32 commit 967cc07

File tree

11 files changed

+881
-84
lines changed

11 files changed

+881
-84
lines changed

docs/src/main/asciidoc/websockets-next-reference.adoc

Lines changed: 145 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -185,8 +185,12 @@ public class MyWebSocket {
185185

186186
==== Request context
187187

188-
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_.
189-
The request context is active during endpoint callback invocation.
188+
Each WebSocket endpoint callback method execution is associated with a new _request context_ (activate CDI request context) if the endpoint is:
189+
190+
* Annotated with the `@RequestScoped` annotation.
191+
* Has a method annotated with a security annotation such as `@RolesAllowed`.
192+
* Depends directly or indirectly on a `@RequestScoped` bean.
193+
* Depends directly or indirectly on a CDI beans secured with a standard security annotation.
190194

191195
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.
192196

@@ -783,6 +787,67 @@ class MyBean {
783787
[[websocket-next-security]]
784788
=== Security
785789

790+
Security capabilities are provided by the Quarkus Security extension.
791+
Any xref:security-identity-providers.adoc[Identity provider] can be used to convert authentication credentials attached to a secure HTTP upgrade into a `SecurityIdentity` instance.
792+
The `SecurityIdentity` is then associated with the websocket connection.
793+
Authorization options are demonstrated in following sections.
794+
795+
NOTE: When OpenID Connect extension is used and token expires, Quarkus automatically closes connection.
796+
797+
[[secure-http-upgrade]]
798+
==== Secure HTTP upgrade
799+
800+
An HTTP upgrade is secured when standard security annotation is placed on an endpoint class or an HTTP Security policy is defined.
801+
The advantage of securing HTTP upgrade is less processing, the authorization is performed early and only once.
802+
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.
803+
804+
.Use standard security annotation to secure an HTTP upgrade
805+
[source, java]
806+
----
807+
package io.quarkus.websockets.next.test.security;
808+
809+
import io.quarkus.security.Authenticated;
810+
import jakarta.inject.Inject;
811+
812+
import io.quarkus.security.identity.SecurityIdentity;
813+
import io.quarkus.websockets.next.OnOpen;
814+
import io.quarkus.websockets.next.OnTextMessage;
815+
import io.quarkus.websockets.next.WebSocket;
816+
817+
@Authenticated <1>
818+
@WebSocket(path = "/end")
819+
public class Endpoint {
820+
821+
@Inject
822+
SecurityIdentity currentIdentity;
823+
824+
@OnOpen
825+
String open() {
826+
return "ready";
827+
}
828+
829+
@OnTextMessage
830+
String echo(String message) {
831+
return message;
832+
}
833+
}
834+
----
835+
<1> Initial HTTP handshake ends with the 401 status for anonymous users.
836+
You can also redirect the handshake request on authorization failure with the `quarkus.websockets-next.server.security.auth-failure-redirect-url` configuration property.
837+
838+
IMPORTANT: HTTP upgrade is only secured when a security annotation is declared on an endpoint class next to the `@WebSocket` annotation.
839+
Placing a security annotation on an endpoint bean will not secure bean methods, only the HTTP upgrade.
840+
You must always verify that your endpoint is secured as intended.
841+
842+
.Use HTTP Security policy to secure an HTTP upgrade
843+
[source,properties]
844+
----
845+
quarkus.http.auth.permission.http-upgrade.paths=/end
846+
quarkus.http.auth.permission.http-upgrade.policy=authenticated
847+
----
848+
849+
==== Secure WebSocket endpoint callback methods
850+
786851
WebSocket endpoint callback methods can be secured with security annotations such as `io.quarkus.security.Authenticated`,
787852
`jakarta.annotation.security.RolesAllowed` and other annotations listed in the xref:security-authorize-web-endpoints-reference.adoc#standard-security-annotations[Supported security annotations] documentation.
788853

@@ -828,60 +893,108 @@ public class Endpoint {
828893
<1> The echo callback method can only be invoked if the current security identity has an `admin` role.
829894
<2> The error handler is invoked in case of the authorization failure.
830895

831-
`SecurityIdentity` is initially created during a secure HTTP upgrade and associated with the websocket connection.
896+
==== Secure server endpoints with permission checkers
832897

833-
NOTE: When OpenID Connect extension is used and token expires, Quarkus automatically closes connection.
898+
WebSocket endpoints can be secured with the xref:security-authorize-web-endpoints-reference.adoc#permission-checker[permission checkers].
899+
We recommend to <<secure-http-upgrade>> rather than individual endpoint methods. For example:
834900

835-
=== Secure HTTP upgrade
901+
.Example of a WebSocket endpoint with secured HTTP upgrade
902+
[source, java]
903+
----
904+
package io.quarkus.websockets.next.test.security;
836905
837-
An HTTP upgrade is secured when standard security annotation is placed on an endpoint class or an HTTP Security policy is defined.
838-
The advantage of securing HTTP upgrade is less processing, the authorization is performed early and only once.
839-
You should always prefer HTTP upgrade security unless, like in th example above, you need to perform action on error.
906+
import io.quarkus.security.PermissionsAllowed;
907+
import io.quarkus.websockets.next.OnTextMessage;
908+
import io.quarkus.websockets.next.WebSocket;
840909
841-
.Use standard security annotation to secure an HTTP upgrade
910+
@PermissionsAllowed("product:premium")
911+
@WebSocket(path = "/product/premium")
912+
public class PremiumProductEndpoint {
913+
914+
@OnTextMessage
915+
PremiumProduct getPremiumProduct(int productId) {
916+
return new PremiumProduct(productId);
917+
}
918+
919+
}
920+
----
921+
922+
.Example of a permission checker authorizing the HTTP upgrade
842923
[source, java]
843924
----
844925
package io.quarkus.websockets.next.test.security;
845926
846-
import io.quarkus.security.Authenticated;
927+
import io.quarkus.security.identity.SecurityIdentity;
928+
import io.quarkus.security.PermissionChecker;
929+
import io.quarkus.vertx.http.runtime.security.HttpSecurityUtils;
930+
import io.vertx.ext.web.RoutingContext;
931+
import jakarta.enterprise.context.ApplicationScoped;
932+
933+
@ApplicationScoped
934+
public class PermissionChecker {
935+
936+
@PermissionChecker("product:premium")
937+
public boolean canGetPremiumProduct(SecurityIdentity securityIdentity) { <1>
938+
String username = currentIdentity.getPrincipal().getName();
939+
940+
RoutingContext routingContext = HttpSecurityUtils.getRoutingContextAttribute(securityIdentity);
941+
String initialHttpUpgradePath = routingContext == null ? null : routingContext.normalizedPath();
942+
if (!isUserAllowedToAccessPath(initialHttpUpgradePath, username)) {
943+
return false;
944+
}
945+
946+
return isPremiumCustomer(username);
947+
}
948+
949+
}
950+
----
951+
<1> A permission checker authorizing an HTTP upgrade must declare exactly one method parameter, the `SecurityIdentity`.
952+
953+
If the security check performs payload-aware authorization, it is also possible to run check on every message. For example:
954+
955+
[source, java]
956+
----
957+
package io.quarkus.websockets.next.test.security;
958+
959+
import io.quarkus.security.PermissionChecker;
960+
import io.quarkus.security.PermissionsAllowed;
847961
import jakarta.inject.Inject;
848962
963+
import io.quarkus.security.ForbiddenException;
849964
import io.quarkus.security.identity.SecurityIdentity;
965+
import io.quarkus.websockets.next.OnError;
850966
import io.quarkus.websockets.next.OnOpen;
851967
import io.quarkus.websockets.next.OnTextMessage;
852968
import io.quarkus.websockets.next.WebSocket;
853969
854-
@Authenticated <1>
855-
@WebSocket(path = "/end")
856-
public class Endpoint {
970+
@WebSocket(path = "/product")
971+
public class ProductEndpoint {
972+
973+
private record Product(int id, String name) {}
857974
858975
@Inject
859976
SecurityIdentity currentIdentity;
860977
861-
@OnOpen
862-
String open() {
863-
return "ready";
864-
}
865-
978+
@PermissionsAllowed("product:get")
866979
@OnTextMessage
867-
String echo(String message) {
868-
return message;
980+
Product getProduct(int productId) { <1>
981+
return new Product(productId, "Product " + productId);
869982
}
870-
}
871-
----
872-
<1> Initial HTTP handshake ends with the 401 status for anonymous users.
873-
You can also redirect the handshake request on authorization failure with the `quarkus.websockets-next.server.security.auth-failure-redirect-url` configuration property.
874983
875-
IMPORTANT: HTTP upgrade is only secured when a security annotation is declared on an endpoint class next to the `@WebSocket` annotation.
876-
Placing a security annotation on an endpoint bean will not secure bean methods, only the HTTP upgrade.
877-
You must always verify that your endpoint is secured as intended.
984+
@OnError
985+
String error(ForbiddenException t) { <2>
986+
return "forbidden:" + currentIdentity.getPrincipal().getName();
987+
}
878988
879-
.Use HTTP Security policy to secure an HTTP upgrade
880-
[source,properties]
881-
----
882-
quarkus.http.auth.permission.http-upgrade.paths=/end
883-
quarkus.http.auth.permission.http-upgrade.policy=authenticated
989+
@PermissionChecker("product:get")
990+
boolean canGetProduct(int productId) {
991+
String username = currentIdentity.getPrincipal().getName();
992+
return currentIdentity.hasRole("admin") || canUserGetProduct(productId, username);
993+
}
994+
}
884995
----
996+
<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.
997+
<2> The error handler is invoked in case of the authorization failure.
885998

886999
=== Inspect and/or reject HTTP upgrade
8871000

extensions/security/runtime/src/main/java/io/quarkus/security/runtime/QuarkusPermissionSecurityIdentityAugmentor.java

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package io.quarkus.security.runtime;
22

33
import java.security.Permission;
4+
import java.util.Map;
5+
import java.util.function.BiFunction;
46
import java.util.function.Function;
57
import java.util.function.Predicate;
68

@@ -33,33 +35,43 @@ public Throwable apply(Throwable throwable) {
3335
return new ForbiddenException(throwable);
3436
}
3537
};
38+
// keep in sync with HttpSecurityUtils
39+
private static final String ROUTING_CONTEXT_ATTRIBUTE = "quarkus.http.routing.context";
3640

37-
private final BlockingSecurityExecutor blockingExecutor;
41+
private final BiFunction<SecurityIdentity, Permission, Uni<Boolean>> permissionChecker;
3842

3943
QuarkusPermissionSecurityIdentityAugmentor(BlockingSecurityExecutor blockingExecutor) {
40-
this.blockingExecutor = blockingExecutor;
44+
this.permissionChecker = new BiFunction<SecurityIdentity, Permission, Uni<Boolean>>() {
45+
@Override
46+
public Uni<Boolean> apply(SecurityIdentity finalIdentity, Permission requiredpermission) {
47+
if (requiredpermission instanceof QuarkusPermission<?> quarkusPermission) {
48+
return quarkusPermission
49+
.isGranted(finalIdentity, blockingExecutor)
50+
.onFailure(NOT_A_FORBIDDEN_EXCEPTION).transform(WRAP_WITH_FORBIDDEN_EXCEPTION);
51+
}
52+
return Uni.createFrom().item(false);
53+
}
54+
};
4155
}
4256

4357
@Override
44-
public Uni<SecurityIdentity> augment(SecurityIdentity identity, AuthenticationRequestContext context) {
58+
public Uni<SecurityIdentity> augment(SecurityIdentity identity, AuthenticationRequestContext context,
59+
Map<String, Object> attributes) {
4560
if (identity.isAnonymous()) {
4661
return Uni.createFrom().item(identity);
4762
}
4863

49-
return Uni.createFrom().item(QuarkusSecurityIdentity
50-
.builder(identity)
51-
.addPermissionChecker(new Function<>() {
52-
@Override
53-
public Uni<Boolean> apply(Permission requiredpermission) {
54-
if (requiredpermission instanceof QuarkusPermission<?> quarkusPermission) {
55-
return quarkusPermission
56-
.isGranted(identity, blockingExecutor)
57-
.onFailure(NOT_A_FORBIDDEN_EXCEPTION).transform(WRAP_WITH_FORBIDDEN_EXCEPTION);
58-
}
59-
return Uni.createFrom().item(false);
60-
}
61-
})
62-
.build());
64+
var identityBuilder = QuarkusSecurityIdentity.builder(identity).addPermissionCheckerWithIdentity(permissionChecker);
65+
if (attributes.containsKey(ROUTING_CONTEXT_ATTRIBUTE)) {
66+
// propagates RoutingContext, useful when CDI request context is not active or the RoutingContext is not there
67+
identityBuilder.addAttribute(ROUTING_CONTEXT_ATTRIBUTE, attributes.get(ROUTING_CONTEXT_ATTRIBUTE));
68+
}
69+
return Uni.createFrom().item(identityBuilder.build());
70+
}
71+
72+
@Override
73+
public Uni<SecurityIdentity> augment(SecurityIdentity identity, AuthenticationRequestContext context) {
74+
return augment(identity, context, Map.of());
6375
}
6476

6577
@Override

extensions/security/runtime/src/main/java/io/quarkus/security/runtime/QuarkusSecurityIdentity.java

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import java.util.List;
1010
import java.util.Map;
1111
import java.util.Set;
12+
import java.util.function.BiFunction;
1213
import java.util.function.Function;
1314
import java.util.stream.Collectors;
1415

@@ -31,7 +32,7 @@ private QuarkusSecurityIdentity(Builder builder) {
3132
this.roles = Collections.unmodifiableSet(builder.roles);
3233
this.credentials = Collections.unmodifiableSet(builder.credentials);
3334
this.attributes = Collections.unmodifiableMap(builder.attributes);
34-
this.permissionCheckers = Collections.unmodifiableList(builder.permissionCheckers);
35+
this.permissionCheckers = getPermissionCheckers(builder, this);
3536
this.anonymous = builder.anonymous;
3637
}
3738

@@ -141,6 +142,18 @@ public Uni<Boolean> apply(Permission permission) {
141142
return builder;
142143
}
143144

145+
private static List<Function<Permission, Uni<Boolean>>> getPermissionCheckers(Builder builder, SecurityIdentity identity) {
146+
for (var checker : builder.permissionCheckersWithIdentity) {
147+
builder.permissionCheckers.add(new Function<Permission, Uni<Boolean>>() {
148+
@Override
149+
public Uni<Boolean> apply(Permission permission) {
150+
return checker.apply(identity, permission);
151+
}
152+
});
153+
}
154+
return Collections.unmodifiableList(builder.permissionCheckers);
155+
}
156+
144157
public static class Builder {
145158

146159
Principal principal;
@@ -149,6 +162,7 @@ public static class Builder {
149162
Set<Permission> permissions = new HashSet<>();
150163
Map<String, Object> attributes = new HashMap<>();
151164
List<Function<Permission, Uni<Boolean>>> permissionCheckers = new ArrayList<>();
165+
List<BiFunction<SecurityIdentity, Permission, Uni<Boolean>>> permissionCheckersWithIdentity = new ArrayList<>();
152166
private boolean anonymous;
153167
boolean built = false;
154168

@@ -268,6 +282,19 @@ public Builder addPermissionChecker(Function<Permission, Uni<Boolean>> function)
268282
return this;
269283
}
270284

285+
/**
286+
* Internal method similar to {@link #addPermissionChecker(Function)}.
287+
* Only difference is that permission checker is defined on both required permission and the identity this
288+
* builder creates.
289+
*/
290+
Builder addPermissionCheckerWithIdentity(BiFunction<SecurityIdentity, Permission, Uni<Boolean>> function) {
291+
if (built) {
292+
throw new IllegalStateException();
293+
}
294+
permissionCheckersWithIdentity.add(function);
295+
return this;
296+
}
297+
271298
/**
272299
* Adds a permission check functions.
273300
*

extensions/security/spi/src/main/java/io/quarkus/security/spi/PermissionsAllowedMetaAnnotationBuildItem.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ public List<AnnotationInstance> getTransitiveInstances() {
4949
return transitiveInstances;
5050
}
5151

52-
private boolean hasPermissionsAllowed(List<AnnotationInstance> instances) {
52+
public boolean hasPermissionsAllowed(List<AnnotationInstance> instances) {
5353
return instances.stream().anyMatch(ai -> metaAnnotationNames.contains(ai.name()));
5454
}
5555

extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityUtils.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import io.vertx.ext.web.RoutingContext;
1313

1414
public final class HttpSecurityUtils {
15+
// keep in sync with QuarkusPermissionSecurityIdentityAugmentor
1516
public final static String ROUTING_CONTEXT_ATTRIBUTE = "quarkus.http.routing.context";
1617
static final String SECURITY_IDENTITIES_ATTRIBUTE = "io.quarkus.security.identities";
1718
static final String COMMON_NAME = "CN";
@@ -55,7 +56,11 @@ public static RoutingContext getRoutingContextAttribute(AuthenticationRequest re
5556
}
5657

5758
public static RoutingContext getRoutingContextAttribute(SecurityIdentity identity) {
58-
return identity.getAttribute(RoutingContext.class.getName());
59+
RoutingContext routingContext = identity.getAttribute(RoutingContext.class.getName());
60+
if (routingContext != null) {
61+
return routingContext;
62+
}
63+
return identity.getAttribute(ROUTING_CONTEXT_ATTRIBUTE);
5964
}
6065

6166
public static RoutingContext getRoutingContextAttribute(Map<String, Object> authenticationRequestAttributes) {

0 commit comments

Comments
 (0)