From f019328caf0d5af58f2c0042169d254dcf411f51 Mon Sep 17 00:00:00 2001 From: Michael Edgar Date: Mon, 4 Nov 2024 14:43:09 -0500 Subject: [PATCH] Scan Spring Security `@Secured` annotation Signed-off-by: Michael Edgar --- .../processor/JavaSecurityProcessor.java | 27 +++++++++++----- extension-spring/pom.xml | 7 +++++ .../spring/SpringAnnotationScanner.java | 31 ++++++++++++++++++- .../openapi/spring/SpringConstants.java | 2 ++ .../resources/GreetingDeleteController.java | 7 ++++- .../GreetingDeleteControllerAlt.java | 8 ++++- ...stBasicSpringDeleteDefinitionScanning.json | 24 +++++++++++--- 7 files changed, 91 insertions(+), 15 deletions(-) diff --git a/core/src/main/java/io/smallrye/openapi/runtime/scanner/processor/JavaSecurityProcessor.java b/core/src/main/java/io/smallrye/openapi/runtime/scanner/processor/JavaSecurityProcessor.java index e7baac1a6..c12bd7902 100644 --- a/core/src/main/java/io/smallrye/openapi/runtime/scanner/processor/JavaSecurityProcessor.java +++ b/core/src/main/java/io/smallrye/openapi/runtime/scanner/processor/JavaSecurityProcessor.java @@ -3,8 +3,11 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.function.Supplier; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -29,7 +32,9 @@ public class JavaSecurityProcessor { public void addRolesAllowedToScopes(String[] roles) { - resourceRolesAllowed = roles; + if (roles != null) { + resourceRolesAllowed.addAll(Arrays.asList(roles)); + } addScopes(roles); } @@ -38,13 +43,18 @@ public void addDeclaredRolesToScopes(String[] roles) { } public void processSecurityRoles(MethodInfo method, Operation operation) { - processSecurityRolesForMethodOperation(method, operation); + processSecurityRolesForMethodOperation(method, operation, + () -> context.annotations().getAnnotationValue(method, SecurityConstants.ROLES_ALLOWED)); + } + + public void processSecurityRoles(MethodInfo method, Operation operation, Supplier roleSupplier) { + processSecurityRolesForMethodOperation(method, operation, roleSupplier); } private final AnnotationScannerContext context; private String currentSecurityScheme; private List currentFlows; - private String[] resourceRolesAllowed; + private final Set resourceRolesAllowed = new LinkedHashSet<>(); public JavaSecurityProcessor(AnnotationScannerContext context) { this.context = context; @@ -53,7 +63,7 @@ public JavaSecurityProcessor(AnnotationScannerContext context) { public void initialize(OpenAPI openApi) { currentSecurityScheme = null; currentFlows = null; - resourceRolesAllowed = null; + resourceRolesAllowed.clear(); checkSecurityScheme(openApi); } @@ -96,21 +106,22 @@ private void addScopes(String[] roles) { * @param method the current JAX-RS method * @param operation the OpenAPI Operation */ - private void processSecurityRolesForMethodOperation(MethodInfo method, Operation operation) { + private void processSecurityRolesForMethodOperation(MethodInfo method, Operation operation, + Supplier roleSupplier) { if (this.currentSecurityScheme != null) { - String[] rolesAllowed = context.annotations().getAnnotationValue(method, SecurityConstants.ROLES_ALLOWED); + String[] rolesAllowed = roleSupplier.get(); if (rolesAllowed != null) { addScopes(rolesAllowed); addRolesAllowed(operation, rolesAllowed); - } else if (this.resourceRolesAllowed != null) { + } else if (!this.resourceRolesAllowed.isEmpty()) { boolean denyAll = context.annotations().getAnnotation(method, SecurityConstants.DENY_ALL) != null; boolean permitAll = context.annotations().getAnnotation(method, SecurityConstants.PERMIT_ALL) != null; if (denyAll) { addRolesAllowed(operation, new String[0]); } else if (!permitAll) { - addRolesAllowed(operation, this.resourceRolesAllowed); + addRolesAllowed(operation, this.resourceRolesAllowed.toArray(String[]::new)); } } } diff --git a/extension-spring/pom.xml b/extension-spring/pom.xml index b4dc54dae..fbc8f9434 100644 --- a/extension-spring/pom.xml +++ b/extension-spring/pom.xml @@ -14,6 +14,7 @@ 6.1.13 + 6.3.4 @@ -63,6 +64,12 @@ spring-webmvc test + + org.springframework.security + spring-security-core + ${version.spring-security} + test + javax.servlet javax.servlet-api diff --git a/extension-spring/src/main/java/io/smallrye/openapi/spring/SpringAnnotationScanner.java b/extension-spring/src/main/java/io/smallrye/openapi/spring/SpringAnnotationScanner.java index eeea62c91..3e964bfe1 100644 --- a/extension-spring/src/main/java/io/smallrye/openapi/spring/SpringAnnotationScanner.java +++ b/extension-spring/src/main/java/io/smallrye/openapi/spring/SpringAnnotationScanner.java @@ -5,9 +5,11 @@ import java.util.Collection; import java.util.HashSet; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.function.Function; +import java.util.stream.Stream; import org.eclipse.microprofile.openapi.OASFactory; import org.eclipse.microprofile.openapi.models.OpenAPI; @@ -24,13 +26,16 @@ import org.jboss.jandex.MethodParameterInfo; import org.jboss.jandex.Type; +import io.smallrye.openapi.api.constants.SecurityConstants; import io.smallrye.openapi.api.util.ListUtil; import io.smallrye.openapi.api.util.MergeUtil; import io.smallrye.openapi.runtime.scanner.AnnotationScannerExtension; import io.smallrye.openapi.runtime.scanner.ResourceParameters; import io.smallrye.openapi.runtime.scanner.dataobject.TypeResolver; +import io.smallrye.openapi.runtime.scanner.processor.JavaSecurityProcessor; import io.smallrye.openapi.runtime.scanner.spi.AbstractAnnotationScanner; import io.smallrye.openapi.runtime.scanner.spi.AnnotationScannerContext; +import io.smallrye.openapi.runtime.util.Annotations; import io.smallrye.openapi.runtime.util.ModelUtil; /** @@ -211,6 +216,15 @@ private OpenAPI processControllerClass(ClassInfo controllerClass) { return openApi; } + @Override + public void processJavaSecurity(AnnotationScannerContext context, ClassInfo resourceClass, OpenAPI openApi) { + super.processJavaSecurity(context, resourceClass, openApi); + JavaSecurityProcessor securityProcessor = context.getJavaSecurityProcessor(); + securityProcessor + .addRolesAllowedToScopes( + context.annotations().getAnnotationValue(resourceClass, SpringConstants.SECURED)); + } + /** * Process the Spring controller Operation methods * @@ -334,7 +348,7 @@ private void processControllerMethod(final ClassInfo resourceClass, processExtensions(context, method, operation); // Process Security Roles - context.getJavaSecurityProcessor().processSecurityRoles(method, operation); + context.getJavaSecurityProcessor().processSecurityRoles(method, operation, () -> getDeclaredRoles(method)); // Now set the operation on the PathItem as appropriate based on the Http method type pathItem.setOperation(methodType, operation); @@ -357,6 +371,21 @@ private void processControllerMethod(final ClassInfo resourceClass, } } + private String[] getDeclaredRoles(MethodInfo method) { + Annotations annotations = context.annotations(); + String[] rolesAllowed = annotations.getAnnotationValue(method, SecurityConstants.ROLES_ALLOWED); + String[] securedRoles = annotations.getAnnotationValue(method, SpringConstants.SECURED); + + if (rolesAllowed == null && securedRoles == null) { + return null; // NOSONAR + } + + return Stream.of(rolesAllowed, securedRoles) + .filter(Objects::nonNull) + .flatMap(Arrays::stream) + .toArray(String[]::new); + } + private ResourceParameters getResourceParameters(final ClassInfo resourceClass, final MethodInfo method) { Function reader = t -> context.io().parameterIO().read(t); diff --git a/extension-spring/src/main/java/io/smallrye/openapi/spring/SpringConstants.java b/extension-spring/src/main/java/io/smallrye/openapi/spring/SpringConstants.java index 51d58d14f..6dedd7c1e 100644 --- a/extension-spring/src/main/java/io/smallrye/openapi/spring/SpringConstants.java +++ b/extension-spring/src/main/java/io/smallrye/openapi/spring/SpringConstants.java @@ -16,6 +16,8 @@ */ public class SpringConstants { + static final DotName SECURED = DotName.createSimple("org.springframework.security.access.annotation.Secured"); + static final DotName REST_CONTROLLER = DotName.createSimple("org.springframework.web.bind.annotation.RestController"); static final DotName REQUEST_MAPPING = DotName.createSimple("org.springframework.web.bind.annotation.RequestMapping"); static final DotName GET_MAPPING = DotName.createSimple("org.springframework.web.bind.annotation.GetMapping"); diff --git a/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingDeleteController.java b/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingDeleteController.java index aa98f13a9..01b644eae 100644 --- a/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingDeleteController.java +++ b/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingDeleteController.java @@ -1,8 +1,11 @@ package test.io.smallrye.openapi.runtime.scanner.resources; +import org.eclipse.microprofile.openapi.annotations.enums.SecuritySchemeType; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.security.SecurityScheme; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.annotation.Secured; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; @@ -17,6 +20,8 @@ */ @RestController @RequestMapping(value = "/greeting", produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.APPLICATION_JSON_VALUE) +@Secured({ "roles:removal" }) +@SecurityScheme(securitySchemeName = "oauth", type = SecuritySchemeType.OAUTH2) public class GreetingDeleteController { // 1) Basic path var test @@ -28,7 +33,7 @@ public void greet(@PathVariable(name = "id") String id) { // 2) ResponseEntity without a type specified @DeleteMapping("/greetWithResponse/{id}") @APIResponse(responseCode = "204", description = "No Content") - public ResponseEntity greetWithResponse(@PathVariable(name = "id") String id) { + public ResponseEntity greetWithResponse(@PathVariable(name = "id") String id) { return ResponseEntity.noContent().build(); } diff --git a/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingDeleteControllerAlt.java b/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingDeleteControllerAlt.java index ff9b3e9bd..6093653c8 100644 --- a/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingDeleteControllerAlt.java +++ b/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingDeleteControllerAlt.java @@ -1,6 +1,10 @@ package test.io.smallrye.openapi.runtime.scanner.resources; +import jakarta.annotation.security.RolesAllowed; + +import org.eclipse.microprofile.openapi.annotations.enums.SecuritySchemeType; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.security.SecurityScheme; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PathVariable; @@ -17,6 +21,8 @@ */ @RestController @RequestMapping(value = "/greeting", produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.APPLICATION_JSON_VALUE) +@RolesAllowed({ "roles:removal" }) +@SecurityScheme(securitySchemeName = "oauth", type = SecuritySchemeType.OAUTH2) public class GreetingDeleteControllerAlt { // 1) Basic path var test @@ -28,7 +34,7 @@ public void greet(@PathVariable(name = "id") String id) { // 2) ResponseEntity without a type specified @RequestMapping(value = "/greetWithResponse/{id}", method = RequestMethod.DELETE) @APIResponse(responseCode = "204", description = "No Content") - public ResponseEntity greetWithResponse(@PathVariable(name = "id") String id) { + public ResponseEntity greetWithResponse(@PathVariable(name = "id") String id) { return ResponseEntity.noContent().build(); } diff --git a/extension-spring/src/test/resources/io/smallrye/openapi/runtime/scanner/resource.testBasicSpringDeleteDefinitionScanning.json b/extension-spring/src/test/resources/io/smallrye/openapi/runtime/scanner/resource.testBasicSpringDeleteDefinitionScanning.json index 9ddddd79b..2114f16f5 100644 --- a/extension-spring/src/test/resources/io/smallrye/openapi/runtime/scanner/resource.testBasicSpringDeleteDefinitionScanning.json +++ b/extension-spring/src/test/resources/io/smallrye/openapi/runtime/scanner/resource.testBasicSpringDeleteDefinitionScanning.json @@ -1,5 +1,12 @@ { "openapi" : "3.1.0", + "components" : { + "securitySchemes" : { + "oauth" : { + "type" : "oauth2" + } + } + }, "paths" : { "/greeting/greet/{id}" : { "delete" : { @@ -15,7 +22,10 @@ "204" : { "description" : "No Content" } - } + }, + "security" : [ { + "oauth" : [ "roles:removal" ] + } ] } }, "/greeting/greetWithResponse/{id}" : { @@ -32,7 +42,10 @@ "204" : { "description" : "No Content" } - } + }, + "security" : [ { + "oauth" : [ "roles:removal" ] + } ] } }, "/greeting/greetWithResponseTyped/{id}" : { @@ -49,8 +62,11 @@ "204" : { "description" : "No Content" } - } + }, + "security" : [ { + "oauth" : [ "roles:removal" ] + } ] } } } -} \ No newline at end of file +}