Skip to content

Commit

Permalink
Merge pull request quarkusio#40957 from michalvavrik/feature/websocke…
Browse files Browse the repository at this point in the history
…ts-next-eager-sec-checks

WebSockets Next: Support for secure upgrade with security annotations only
  • Loading branch information
sberyozkin authored Jun 11, 2024
2 parents 6de81cf + 5332010 commit b8994ac
Show file tree
Hide file tree
Showing 20 changed files with 1,331 additions and 105 deletions.
54 changes: 47 additions & 7 deletions docs/src/main/asciidoc/websockets-next-reference.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -675,18 +675,58 @@ public class Endpoint {

`SecurityIdentity` is initially created during a secure HTTP upgrade and associated with the websocket connection.

Currently, for an HTTP upgrade be secured, users must configure an HTTP policy protecting the HTTP upgrade path.
For example, to secure the `open()` method in the above websocket endpoint, one can add the following authentication policy:
NOTE: When OpenID Connect extension is used and token expires, Quarkus automatically closes connection.

[source,properties]
== Secure HTTP upgrade

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.

.Use standard security annotation to secure an HTTP upgrade
[source, java]
----
quarkus.http.auth.permission.secured.paths=/end
quarkus.http.auth.permission.secured.policy=authenticated
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.

Other options for securing HTTP upgrade requests, such as using the security annotations, will be explored in the future.
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.

NOTE: When OpenID Connect extension is used and token expires, Quarkus automatically closes connection.
.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
----

== Inspect and/or reject HTTP upgrade

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.quarkus.security.deployment;

import static io.quarkus.arc.processor.DotNames.CLASS;
import static io.quarkus.arc.processor.DotNames.STRING;
import static io.quarkus.security.PermissionsAllowed.AUTODETECTED;
import static io.quarkus.security.PermissionsAllowed.PERMISSION_TO_ACTION_SEPARATOR;
Expand Down Expand Up @@ -34,7 +35,9 @@

interface PermissionSecurityChecks {

Map<MethodInfo, SecurityCheck> get();
Map<MethodInfo, SecurityCheck> getMethodSecurityChecks();

Map<DotName, SecurityCheck> getClassNameSecurityChecks();

Set<String> permissionClasses();

Expand All @@ -43,8 +46,8 @@ final class PermissionSecurityChecksBuilder {
private static final DotName STRING_PERMISSION = DotName.createSimple(StringPermission.class);
private static final DotName PERMISSIONS_ALLOWED_INTERCEPTOR = DotName
.createSimple(PermissionsAllowedInterceptor.class);
private final Map<MethodInfo, List<List<PermissionKey>>> methodToPermissionKeys = new HashMap<>();
private final Map<MethodInfo, LogicalAndPermissionPredicate> methodToPredicate = new HashMap<>();
private final Map<AnnotationTarget, List<List<PermissionKey>>> targetToPermissionKeys = new HashMap<>();
private final Map<AnnotationTarget, LogicalAndPermissionPredicate> targetToPredicate = new HashMap<>();
private final Map<String, MethodInfo> classSignatureToConstructor = new HashMap<>();
private final SecurityCheckRecorder recorder;

Expand All @@ -53,22 +56,37 @@ public PermissionSecurityChecksBuilder(SecurityCheckRecorder recorder) {
}

PermissionSecurityChecks build() {
final Map<LogicalAndPermissionPredicate, SecurityCheck> cache = new HashMap<>();
final Map<MethodInfo, SecurityCheck> methodToCheck = new HashMap<>();
final Map<DotName, SecurityCheck> classNameToCheck = new HashMap<>();
for (var targetToPredicate : targetToPredicate.entrySet()) {
SecurityCheck check = cache.computeIfAbsent(targetToPredicate.getValue(),
new Function<LogicalAndPermissionPredicate, SecurityCheck>() {
@Override
public SecurityCheck apply(LogicalAndPermissionPredicate predicate) {
return createSecurityCheck(predicate);
}
});

var annotationTarget = targetToPredicate.getKey();
if (annotationTarget.kind() == AnnotationTarget.Kind.CLASS) {
DotName className = annotationTarget.asClass().name();
classNameToCheck.put(className, check);
} else {
MethodInfo securedMethod = annotationTarget.asMethod();
methodToCheck.put(securedMethod, check);
}
}

return new PermissionSecurityChecks() {
@Override
public Map<MethodInfo, SecurityCheck> get() {
final Map<LogicalAndPermissionPredicate, SecurityCheck> cache = new HashMap<>();
final Map<MethodInfo, SecurityCheck> methodToCheck = new HashMap<>();
for (var methodToPredicate : methodToPredicate.entrySet()) {
SecurityCheck check = cache.computeIfAbsent(methodToPredicate.getValue(),
new Function<LogicalAndPermissionPredicate, SecurityCheck>() {
@Override
public SecurityCheck apply(LogicalAndPermissionPredicate predicate) {
return createSecurityCheck(predicate);
}
});
methodToCheck.put(methodToPredicate.getKey(), check);
}
return methodToCheck;
public Map<MethodInfo, SecurityCheck> getMethodSecurityChecks() {
return Map.copyOf(methodToCheck);
}

@Override
public Map<DotName, SecurityCheck> getClassNameSecurityChecks() {
return Map.copyOf(classNameToCheck);
}

@Override
Expand Down Expand Up @@ -99,8 +117,8 @@ public Set<String> permissionClasses() {
*/
PermissionSecurityChecksBuilder createPermissionPredicates() {
Map<PermissionCacheKey, PermissionWrapper> permissionCache = new HashMap<>();
for (Map.Entry<MethodInfo, List<List<PermissionKey>>> entry : methodToPermissionKeys.entrySet()) {
final MethodInfo securedMethod = entry.getKey();
for (var entry : targetToPermissionKeys.entrySet()) {
final AnnotationTarget securedTarget = entry.getKey();
final LogicalAndPermissionPredicate predicate = new LogicalAndPermissionPredicate();

// 'AND' operands
Expand All @@ -113,7 +131,7 @@ PermissionSecurityChecksBuilder createPermissionPredicates() {
// 'AND' operands

for (PermissionKey permissionKey : permissionKeys) {
var permission = createPermission(permissionKey, securedMethod, permissionCache);
var permission = createPermission(permissionKey, securedTarget, permissionCache);
if (permission.isComputed()) {
predicate.markAsComputed();
}
Expand All @@ -128,15 +146,15 @@ PermissionSecurityChecksBuilder createPermissionPredicates() {
predicate.and(orPredicate);

for (PermissionKey permissionKey : permissionKeys) {
var permission = createPermission(permissionKey, securedMethod, permissionCache);
var permission = createPermission(permissionKey, securedTarget, permissionCache);
if (permission.isComputed()) {
predicate.markAsComputed();
}
orPredicate.or(permission);
}
}
}
methodToPredicate.put(securedMethod, predicate);
targetToPredicate.put(securedTarget, predicate);
}
return this;
}
Expand All @@ -153,7 +171,7 @@ private boolean isInclusive(List<PermissionKey> permissionKeys) {
}

PermissionSecurityChecksBuilder validatePermissionClasses(IndexView index) {
for (List<List<PermissionKey>> keyLists : methodToPermissionKeys.values()) {
for (List<List<PermissionKey>> keyLists : targetToPermissionKeys.values()) {
for (List<PermissionKey> keyList : keyLists) {
for (PermissionKey key : keyList) {
if (!classSignatureToConstructor.containsKey(key.classSignature())) {
Expand Down Expand Up @@ -187,7 +205,8 @@ PermissionSecurityChecksBuilder validatePermissionClasses(IndexView index) {

PermissionSecurityChecksBuilder gatherPermissionsAllowedAnnotations(List<AnnotationInstance> instances,
Map<MethodInfo, AnnotationInstance> alreadyCheckedMethods,
Map<ClassInfo, AnnotationInstance> alreadyCheckedClasses) {
Map<ClassInfo, AnnotationInstance> alreadyCheckedClasses,
List<AnnotationInstance> additionalClassInstances) {

// make sure we process annotations on methods first
instances.sort(new Comparator<AnnotationInstance>() {
Expand Down Expand Up @@ -217,7 +236,7 @@ public int compare(AnnotationInstance o1, AnnotationInstance o2) {
methodInfo.name(), methodInfo.declaringClass()));
}

gatherPermissionKeys(instance, methodInfo, cache, methodToPermissionKeys);
gatherPermissionKeys(instance, methodInfo, cache, targetToPermissionKeys);
} else {
// class annotation

Expand Down Expand Up @@ -245,7 +264,7 @@ public int compare(AnnotationInstance o1, AnnotationInstance o2) {
// ignore method annotated with other security annotation
boolean noMethodLevelSecurityAnnotation = !alreadyCheckedMethods.containsKey(methodInfo);
// ignore method annotated with method-level @PermissionsAllowed
boolean noMethodLevelPermissionsAllowed = !methodToPermissionKeys.containsKey(methodInfo);
boolean noMethodLevelPermissionsAllowed = !targetToPermissionKeys.containsKey(methodInfo);
if (noMethodLevelSecurityAnnotation && noMethodLevelPermissionsAllowed) {

gatherPermissionKeys(instance, methodInfo, cache, classMethodToPermissionKeys);
Expand All @@ -261,12 +280,16 @@ public int compare(AnnotationInstance o1, AnnotationInstance o2) {
}
}
}
methodToPermissionKeys.putAll(classMethodToPermissionKeys);
targetToPermissionKeys.putAll(classMethodToPermissionKeys);
for (var instance : additionalClassInstances) {
gatherPermissionKeys(instance, instance.target(), cache, targetToPermissionKeys);
}
return this;
}

private static void gatherPermissionKeys(AnnotationInstance instance, MethodInfo methodInfo, List<PermissionKey> cache,
Map<MethodInfo, List<List<PermissionKey>>> methodToPermissionKeys) {
private static <T extends AnnotationTarget> void gatherPermissionKeys(AnnotationInstance instance, T annotationTarget,
List<PermissionKey> cache,
Map<T, List<List<PermissionKey>>> targetToPermissionKeys) {
// @PermissionsAllowed value is in format permission:action, permission2:action, permission:action2, permission3
// here we transform it to permission -> actions
final var permissionToActions = new HashMap<String, Set<String>>();
Expand Down Expand Up @@ -299,9 +322,15 @@ private static void gatherPermissionKeys(AnnotationInstance instance, MethodInfo
}

if (permissionToActions.isEmpty()) {
throw new RuntimeException(String.format(
"Method '%s' was annotated with '@PermissionsAllowed', but no valid permission was provided",
methodInfo.name()));
if (annotationTarget.kind() == AnnotationTarget.Kind.METHOD) {
throw new RuntimeException(String.format(
"Method '%s' was annotated with '@PermissionsAllowed', but no valid permission was provided",
annotationTarget.asMethod().name()));
} else {
throw new RuntimeException(String.format(
"Class '%s' was annotated with '@PermissionsAllowed', but no valid permission was provided",
annotationTarget.asClass().name()));
}
}

// permissions specified via @PermissionsAllowed has 'one of' relation, therefore we put them in one list
Expand All @@ -324,13 +353,8 @@ private static void gatherPermissionKeys(AnnotationInstance instance, MethodInfo
}

// store annotation value as permission keys
methodToPermissionKeys
.computeIfAbsent(methodInfo, new Function<MethodInfo, List<List<PermissionKey>>>() {
@Override
public List<List<PermissionKey>> apply(MethodInfo methodInfo) {
return new ArrayList<>();
}
})
targetToPermissionKeys
.computeIfAbsent(annotationTarget, at -> new ArrayList<>())
.add(List.copyOf(orPermissions));
}

Expand Down Expand Up @@ -378,10 +402,10 @@ private SecurityCheck createSecurityCheck(LogicalAndPermissionPredicate andPredi
return securityCheck;
}

private PermissionWrapper createPermission(PermissionKey permissionKey, MethodInfo securedMethod,
private PermissionWrapper createPermission(PermissionKey permissionKey, AnnotationTarget securedTarget,
Map<PermissionCacheKey, PermissionWrapper> cache) {
var constructor = classSignatureToConstructor.get(permissionKey.classSignature());
return cache.computeIfAbsent(new PermissionCacheKey(permissionKey, securedMethod, constructor),
return cache.computeIfAbsent(new PermissionCacheKey(permissionKey, securedTarget, constructor),
new Function<PermissionCacheKey, PermissionWrapper>() {
@Override
public PermissionWrapper apply(PermissionCacheKey permissionCacheKey) {
Expand Down Expand Up @@ -568,8 +592,14 @@ private static final class PermissionCacheKey {
private final boolean computed;
private final boolean passActionsToConstructor;

private PermissionCacheKey(PermissionKey permissionKey, MethodInfo securedMethod, MethodInfo constructor) {
private PermissionCacheKey(PermissionKey permissionKey, AnnotationTarget securedTarget, MethodInfo constructor) {
if (isComputed(permissionKey, constructor)) {
if (securedTarget.kind() != AnnotationTarget.Kind.METHOD) {
throw new IllegalArgumentException(
"@PermissionAllowed instance that accepts method arguments must be placed on a method");
}
MethodInfo securedMethod = securedTarget.asMethod();

// computed permission
this.permissionKey = permissionKey;
this.computed = true;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package io.quarkus.security.deployment;

import org.jboss.jandex.AnnotationInstance;
import org.jboss.jandex.DotName;

import io.quarkus.builder.item.MultiBuildItem;

/**
* Registers security check against {@link io.quarkus.security.spi.ClassSecurityCheckStorageBuildItem}
* for security annotation instances passed in this build item.
*/
final class RegisterClassSecurityCheckBuildItem extends MultiBuildItem {

final DotName className;
final AnnotationInstance securityAnnotationInstance;

RegisterClassSecurityCheckBuildItem(DotName className, AnnotationInstance securityAnnotationInstance) {
this.className = className;
this.securityAnnotationInstance = securityAnnotationInstance;
}
}
Loading

0 comments on commit b8994ac

Please sign in to comment.