diff --git a/docs/src/main/asciidoc/security-authentication-mechanisms.adoc b/docs/src/main/asciidoc/security-authentication-mechanisms.adoc index 6e7a63d589538d..5b03f2b92d3d7e 100644 --- a/docs/src/main/asciidoc/security-authentication-mechanisms.adoc +++ b/docs/src/main/asciidoc/security-authentication-mechanisms.adoc @@ -361,6 +361,130 @@ quarkus.http.auth.permission.bearer.auth-mechanism=bearer Ensure that the value of the `auth-mechanism` property matches the authentication scheme supported by `HttpAuthenticationMechanism`, for example, `basic`, `bearer`, or `form`. +=== Endpoint-specific authentication mechanisms + +It is possible to select authentication mechanism specific for each Jakarta REST endpoint and Jakarta REST resource with annotation. +This feature is only enabled when <> is disabled, for authentication selection must happen after a REST endpoint has been matched. +Here is how you can select <> mechanism per a REST endpoint basis: + +[source,properties] +---- +quarkus.http.auth.proactive=false +---- + +[source,java] +---- +import io.quarkus.oidc.Bearer; +import io.quarkus.oidc.CodeFlow; +import jakarta.annotation.security.RolesAllowed; +import jakarta.ws.rs.Path; + +@Path("oidc") +public class OidcResource1 { + + @Bearer <1> <2> + @Path("bearer") + public String bearerAuthMechanism() { + return "bearer"; + } + + @RolesAllowed <3> + @CodeFlow <4> + @Path("code-flow") + public String codeFlowAuthMechanism() { + return "code-flow"; + } + +} +---- +<1> The REST endpoint `/oidc/bearer` can only ever be accessed by using the xref:security-oidc-bearer-token-authentication.adoc[Bearer token authentication]. +<2> This endpoint requires authentication, because when no standard security annotation accompanies the `@Bearer` annotation, the `@Authenticated` annotation is added by default. +<3> The `@CodeFlow` annotation can be combined with any other standard security annotation like `@RolesAllowed`, `@PermissionsAllowed` and others. +<4> The REST endpoint `/oidc/code-flow` can only ever be accessed by using the xref:security-oidc-code-flow-authentication.adoc[OIDC authorization code flow mechanism]. + +.Supported authentication mechanism annotations +|=== +^|Authentication mechanism^| Annotation + +s|Basic authentication mechanism ^|`io.quarkus.vertx.http.runtime.security.Basic` +s|Form-based authentication mechanism ^|`io.quarkus.vertx.http.runtime.security.Form` +s|Mutual TLS authentication mechanism ^|`io.quarkus.vertx.http.runtime.security.MTLS` +s|WebAuthn authentication mechanism ^|`io.quarkus.security.webauthn.WebAuthn` +s|Bearer token authentication mechanism ^|`io.quarkus.oidc.Bearer` +s|OIDC authorization code flow mechanism ^|`io.quarkus.oidc.CodeFlow` +|=== + +TIP: Quarkus automatically secures endpoints annotated with the authentication mechanism annotation. When no standard security annotation is present on the REST endpoint and resource, the `io.quarkus.security.Authenticated` annotation is added for you. + +It is also possible to use the `io.quarkus.vertx.http.runtime.security.HttpAuthMechanism` annotation to select any authentication mechanism based on its scheme. +Annotation-based analogy to the `quarkus.http.auth.permission.basic.auth-mechanism=basic` configuration property is the `@HttpAuthMechanism("basic")` annotation. + +[NOTE] +==== +For consistency with various Jakarta EE specifications, it is recommended to always repeat annotations instead of relying on annotation inheritance. +If you can't avoid relying on annotation inheritance, please remember following points and verify you test authorization: + +* When used with the RESTEasy Classic, using the `io.quarkus.vertx.http.runtime.security.HttpAuthMechanism` annotation on interface and overridden methods is not supported. + +* Standard security annotations placed on interfaces are ignored, therefore even the `@Authenticated` annotation added for you by default will be ignored. + +* Things are simpler with RESTEasy Reactive, the selected authentication mechanism always come from the resource class, where the endpoint declaration is annotated with `jakarta.ws.rs.Path` as you can see in the example below: + +.RESTEasy Reactive authentication mechanism selection inheritance example +[source,java] +---- +@Form +public abstract class AbstractHelloResource { + + @GET + @Path("/world") <1> + public String helloWorld() { + return "Hello World"; + } + + @MTLS + @GET + @Path("/city") <2> + public String helloCity() { + return "Hello City"; + } + + public String helloVillage() { + return "."; + } + +} + +@Authenticated +@Basic +@Path("/hello") +public class HelloResource extends AbstractHelloResource { + + @Override + public String helloWorld() { + return super.helloWorld() + "!"; + } + + @Override + public String helloCity() { + return super.helloCity() + "?"; + } + + @Override + @GET + @Path("/village") <3> + public String helloVillage() { + return "Hello Village" + super.helloVillage(); + } +} +---- +<1> The REST endpoint `/hello/world` will require the Form-based authentication. +<2> The REST endpoint `/hello/city` will require the mutual TLS authentication. +<3> The REST endpoint `hello/village` will require the Basic authentication. +==== + + +[[proactive-auth]] == Proactive authentication Proactive authentication is enabled in Quarkus by default. diff --git a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/OidcBuildStep.java b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/OidcBuildStep.java index daa6a390ee240c..12e8dccdc98c92 100644 --- a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/OidcBuildStep.java +++ b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/OidcBuildStep.java @@ -1,23 +1,15 @@ package io.quarkus.oidc.deployment; -import static io.quarkus.vertx.http.deployment.EagerSecurityInterceptorCandidateBuildItem.hasProperEndpointModifiers; -import static org.jboss.jandex.AnnotationTarget.Kind.CLASS; -import static org.jboss.jandex.AnnotationTarget.Kind.METHOD; +import static io.quarkus.oidc.common.runtime.OidcConstants.BEARER_SCHEME; +import static io.quarkus.oidc.common.runtime.OidcConstants.CODE_FLOW_CODE; -import java.util.HashMap; -import java.util.Map; +import java.util.List; import java.util.function.BooleanSupplier; -import java.util.function.Consumer; -import java.util.stream.Collectors; import jakarta.inject.Singleton; import org.eclipse.microprofile.jwt.Claim; -import org.jboss.jandex.AnnotationInstance; -import org.jboss.jandex.AnnotationTarget; import org.jboss.jandex.DotName; -import org.jboss.jandex.IndexView; -import org.jboss.jandex.MethodInfo; import org.jboss.logging.Logger; import io.quarkus.arc.deployment.AdditionalBeanBuildItem; @@ -36,6 +28,8 @@ import io.quarkus.deployment.builditem.ExtensionSslNativeSupportBuildItem; import io.quarkus.deployment.builditem.RuntimeConfigSetupCompleteBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; +import io.quarkus.oidc.Bearer; +import io.quarkus.oidc.CodeFlow; import io.quarkus.oidc.SecurityEvent; import io.quarkus.oidc.Tenant; import io.quarkus.oidc.TokenIntrospectionCache; @@ -56,14 +50,14 @@ import io.quarkus.oidc.runtime.providers.AzureAccessTokenCustomizer; import io.quarkus.runtime.TlsConfig; import io.quarkus.vertx.core.deployment.CoreVertxBuildItem; -import io.quarkus.vertx.http.deployment.EagerSecurityInterceptorCandidateBuildItem; +import io.quarkus.vertx.http.deployment.EagerSecurityInterceptorBindingBuildItem; +import io.quarkus.vertx.http.deployment.HttpAuthMechanismAnnotationBuildItem; import io.quarkus.vertx.http.deployment.SecurityInformationBuildItem; import io.quarkus.vertx.http.runtime.HttpBuildTimeConfig; import io.smallrye.jwt.auth.cdi.ClaimValueProducer; import io.smallrye.jwt.auth.cdi.CommonJwtProducer; import io.smallrye.jwt.auth.cdi.JsonValueProducer; import io.smallrye.jwt.auth.cdi.RawClaimTypeProducer; -import io.vertx.ext.web.RoutingContext; @BuildSteps(onlyIf = OidcBuildStep.IsEnabled.class) public class OidcBuildStep { @@ -158,80 +152,25 @@ public void findSecurityEventObservers( @BuildStep @Record(ExecutionTime.STATIC_INIT) - public void produceTenantResolverInterceptors(CombinedIndexBuildItem indexBuildItem, - Capabilities capabilities, OidcRecorder recorder, - BuildProducer producer, - HttpBuildTimeConfig buildTimeConfig) { + public void registerTenantResolverInterceptor(Capabilities capabilities, OidcRecorder recorder, + HttpBuildTimeConfig buildTimeConfig, + CombinedIndexBuildItem combinedIndexBuildItem, + BuildProducer bindingProducer) { if (!buildTimeConfig.auth.proactive && (capabilities.isPresent(Capability.RESTEASY_REACTIVE) || capabilities.isPresent(Capability.RESTEASY))) { - // provide method interceptor that will be run before security checks - - // collect endpoint candidates - IndexView index = indexBuildItem.getIndex(); - Map candidateToTenant = new HashMap<>(); - - for (AnnotationInstance annotation : index.getAnnotations(TENANT_NAME)) { - - // validate tenant id - AnnotationTarget target = annotation.target(); - if (annotation.value() == null || annotation.value().asString().isEmpty()) { - LOG.warnf("Annotation instance @Tenant placed on %s did not provide valid tenant", toTargetName(target)); - continue; - } - - // collect annotation instance methods - String tenant = annotation.value().asString(); - if (target.kind() == METHOD) { - MethodInfo method = target.asMethod(); - if (hasProperEndpointModifiers(method)) { - candidateToTenant.put(method, tenant); - } else { - LOG.warnf("Method %s is not valid endpoint, but is annotated with the '@Tenant' annotation", - toTargetName(target)); - } - } else if (target.kind() == CLASS) { - // collect endpoint candidates; we only collect candidates, extensions like - // RESTEasy Reactive and others are still in control of endpoint selection and interceptors - // are going to be applied only on the actual endpoints - for (MethodInfo method : target.asClass().methods()) { - if (hasProperEndpointModifiers(method)) { - candidateToTenant.put(method, tenant); - } - } - } - } - - // create 'interceptor' for each tenant that puts tenant id into routing context - if (!candidateToTenant.isEmpty()) { - - Map> tenantToInterceptor = candidateToTenant - .values() - .stream() - .distinct() - .map(tenant -> Map.entry(tenant, recorder.createTenantResolverInterceptor(tenant))) - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); - - candidateToTenant.forEach((method, tenant) -> { - - // transform method info to description - String[] paramTypes = method.parameterTypes().stream().map(t -> t.name().toString()).toArray(String[]::new); - String className = method.declaringClass().name().toString(); - String methodName = method.name(); - var description = recorder.methodInfoToDescription(className, methodName, paramTypes); - - producer.produce(new EagerSecurityInterceptorCandidateBuildItem(method, description, - tenantToInterceptor.get(tenant))); - }); + var annotationInstances = combinedIndexBuildItem.getIndex().getAnnotations(TENANT_NAME); + if (!annotationInstances.isEmpty()) { + // register method interceptor that will be run before security checks + bindingProducer.produce( + new EagerSecurityInterceptorBindingBuildItem(TENANT_NAME, recorder.tenantResolverInterceptorCreator())); } } } - private static String toTargetName(AnnotationTarget target) { - if (target.kind() == CLASS) { - return target.asClass().name().toString(); - } else { - return target.asMethod().declaringClass().name().toString() + "#" + target.asMethod().name(); - } + @BuildStep + List registerHttpAuthMechanismAnnotation() { + return List.of(new HttpAuthMechanismAnnotationBuildItem(DotName.createSimple(CodeFlow.class), CODE_FLOW_CODE), + new HttpAuthMechanismAnnotationBuildItem(DotName.createSimple(Bearer.class), BEARER_SCHEME)); } public static class IsEnabled implements BooleanSupplier { diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/Bearer.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/Bearer.java new file mode 100644 index 00000000000000..366c9f71cd5b9b --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/Bearer.java @@ -0,0 +1,20 @@ +package io.quarkus.oidc; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.quarkus.vertx.http.runtime.security.HttpAuthMechanism; + +/** + * Selects {@link io.quarkus.oidc.runtime.BearerAuthenticationMechanism}. + * Equivalent to '@HttpAuthMechanism("Bearer")'. + * + * @see HttpAuthMechanism for more information + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE, ElementType.METHOD }) +public @interface Bearer { + +} diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/CodeFlow.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/CodeFlow.java new file mode 100644 index 00000000000000..c2d72a76aea756 --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/CodeFlow.java @@ -0,0 +1,21 @@ +package io.quarkus.oidc; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.quarkus.oidc.runtime.CodeAuthenticationMechanism; +import io.quarkus.vertx.http.runtime.security.HttpAuthMechanism; + +/** + * Selects {@link CodeAuthenticationMechanism}. + * Equivalent to '@HttpAuthMechanism("code")'. + * + * @see HttpAuthMechanism for more information + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE, ElementType.METHOD }) +public @interface CodeFlow { + +} diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java index 854ade5b407881..401425e10bf7e8 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java @@ -28,11 +28,9 @@ import io.quarkus.oidc.common.runtime.OidcCommonConfig; import io.quarkus.oidc.common.runtime.OidcCommonUtils; import io.quarkus.runtime.LaunchMode; -import io.quarkus.runtime.RuntimeValue; import io.quarkus.runtime.TlsConfig; import io.quarkus.runtime.annotations.Recorder; import io.quarkus.runtime.configuration.ConfigurationException; -import io.quarkus.security.spi.runtime.MethodDescription; import io.smallrye.jwt.algorithm.KeyEncryptionAlgorithm; import io.smallrye.jwt.util.KeyUtils; import io.smallrye.mutiny.Uni; @@ -87,10 +85,6 @@ public Uni apply(OidcTenantConfig config) { }; } - public RuntimeValue methodInfoToDescription(String className, String methodName, String[] paramTypes) { - return new RuntimeValue<>(new MethodDescription(className, methodName, paramTypes)); - } - private Uni createDynamicTenantContext(Vertx vertx, OidcTenantConfig oidcConfig, TlsConfig tlsConfig, String tenantId) { @@ -485,11 +479,16 @@ private static OidcConfigurationMetadata createLocalMetadata(OidcTenantConfig oi oidcConfig.token.issuer.orElse(null)); } - public Consumer createTenantResolverInterceptor(String tenantId) { - return new Consumer() { + public Function> tenantResolverInterceptorCreator() { + return new Function>() { @Override - public void accept(RoutingContext routingContext) { - routingContext.put(OidcUtils.TENANT_ID_ATTRIBUTE, tenantId); + public Consumer apply(String tenantId) { + return new Consumer() { + @Override + public void accept(RoutingContext routingContext) { + routingContext.put(OidcUtils.TENANT_ID_ATTRIBUTE, tenantId); + } + }; } }; } diff --git a/extensions/resteasy-classic/resteasy/deployment/src/main/java/io/quarkus/resteasy/deployment/RestPathAnnotationProcessor.java b/extensions/resteasy-classic/resteasy/deployment/src/main/java/io/quarkus/resteasy/deployment/RestPathAnnotationProcessor.java index 5959d9e09c1990..2a1fba336a5033 100644 --- a/extensions/resteasy-classic/resteasy/deployment/src/main/java/io/quarkus/resteasy/deployment/RestPathAnnotationProcessor.java +++ b/extensions/resteasy-classic/resteasy/deployment/src/main/java/io/quarkus/resteasy/deployment/RestPathAnnotationProcessor.java @@ -18,6 +18,7 @@ import io.quarkus.arc.deployment.AdditionalBeanBuildItem; import io.quarkus.arc.deployment.AnnotationsTransformerBuildItem; +import io.quarkus.arc.processor.AnnotationStore; import io.quarkus.arc.processor.AnnotationsTransformer; import io.quarkus.deployment.Capabilities; import io.quarkus.deployment.Capability; @@ -182,7 +183,7 @@ static Optional searchPathAnnotationOnInterfaces(CombinedInd * @param resultAcc accumulator for tail-recursion * @return Collection of all interfaces und their parents. Never null. */ - private static Collection getAllClassInterfaces( + static Collection getAllClassInterfaces( CombinedIndexBuildItem index, Collection classInfos, List resultAcc) { @@ -216,6 +217,21 @@ static boolean isRestEndpointMethod(CombinedIndexBuildItem index, MethodInfo met return true; } + static boolean isRestEndpointMethod(CombinedIndexBuildItem index, MethodInfo methodInfo, AnnotationStore annotationStore) { + + if (!annotationStore.hasAnnotation(methodInfo, REST_PATH)) { + // Check for @Path on class and not method + for (AnnotationInstance annotation : annotationStore.getAnnotations(methodInfo)) { + if (ResteasyDotNames.JAXRS_METHOD_ANNOTATIONS.contains(annotation.name())) { + return true; + } + } + // Search for interface + return searchPathAnnotationOnInterfaces(index, methodInfo).isPresent(); + } + return true; + } + private boolean notRequired(Capabilities capabilities, Optional metricsCapability) { return capabilities.isMissing(Capability.RESTEASY) || diff --git a/extensions/resteasy-classic/resteasy/deployment/src/main/java/io/quarkus/resteasy/deployment/ResteasyBuiltinsProcessor.java b/extensions/resteasy-classic/resteasy/deployment/src/main/java/io/quarkus/resteasy/deployment/ResteasyBuiltinsProcessor.java index df5f2f1a1ff3e1..53a8ac2000d2d1 100644 --- a/extensions/resteasy-classic/resteasy/deployment/src/main/java/io/quarkus/resteasy/deployment/ResteasyBuiltinsProcessor.java +++ b/extensions/resteasy-classic/resteasy/deployment/src/main/java/io/quarkus/resteasy/deployment/ResteasyBuiltinsProcessor.java @@ -5,16 +5,23 @@ import static io.quarkus.security.spi.SecurityTransformerUtils.hasSecurityAnnotation; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Objects; -import java.util.Optional; import java.util.stream.Collectors; +import org.jboss.jandex.AnnotationInstance; +import org.jboss.jandex.AnnotationTarget; import org.jboss.jandex.ClassInfo; import org.jboss.jandex.DotName; import org.jboss.jandex.MethodInfo; +import org.jboss.logging.Logger; import io.quarkus.arc.deployment.AdditionalBeanBuildItem; +import io.quarkus.arc.deployment.BeanRegistrationPhaseBuildItem; +import io.quarkus.arc.processor.AnnotationStore; +import io.quarkus.arc.processor.BuildExtension; import io.quarkus.deployment.Capabilities; import io.quarkus.deployment.Capability; import io.quarkus.deployment.IsDevelopment; @@ -40,8 +47,10 @@ import io.quarkus.resteasy.runtime.vertx.JsonObjectReader; import io.quarkus.resteasy.runtime.vertx.JsonObjectWriter; import io.quarkus.resteasy.server.common.deployment.ResteasyDeploymentBuildItem; +import io.quarkus.runtime.configuration.ConfigurationException; import io.quarkus.security.spi.AdditionalSecuredMethodsBuildItem; -import io.quarkus.vertx.http.deployment.EagerSecurityInterceptorBuildItem; +import io.quarkus.vertx.http.deployment.EagerSecurityInterceptorBindingBuildItem; +import io.quarkus.vertx.http.deployment.EagerSecurityInterceptorMethodsBuildItem; import io.quarkus.vertx.http.deployment.HttpRootPathBuildItem; import io.quarkus.vertx.http.deployment.devmode.NotFoundPageDisplayableEndpointBuildItem; import io.quarkus.vertx.http.deployment.devmode.RouteDescriptionBuildItem; @@ -51,6 +60,7 @@ public class ResteasyBuiltinsProcessor { protected static final String META_INF_RESOURCES = "META-INF/resources"; + private static final Logger LOG = Logger.getLogger(ResteasyBuiltinsProcessor.class); @BuildStep void setUpDenyAllJaxRs(CombinedIndexBuildItem index, @@ -92,7 +102,7 @@ void setUpDenyAllJaxRs(CombinedIndexBuildItem index, @BuildStep void setUpSecurity(BuildProducer providers, BuildProducer additionalBeanBuildItem, Capabilities capabilities, - Optional eagerSecurityInterceptors) { + List eagerSecurityInterceptorBindings) { providers.produce(new ResteasyJaxrsProviderBuildItem(UnauthorizedExceptionMapper.class.getName())); providers.produce(new ResteasyJaxrsProviderBuildItem(ForbiddenExceptionMapper.class.getName())); providers.produce(new ResteasyJaxrsProviderBuildItem(AuthenticationFailedExceptionMapper.class.getName())); @@ -102,7 +112,7 @@ void setUpSecurity(BuildProducer providers, if (capabilities.isPresent(Capability.SECURITY)) { providers.produce(new ResteasyJaxrsProviderBuildItem(SecurityContextFilter.class.getName())); additionalBeanBuildItem.produce(AdditionalBeanBuildItem.unremovableOf(SecurityContextFilter.class)); - if (eagerSecurityInterceptors.isPresent()) { + if (!eagerSecurityInterceptorBindings.isEmpty()) { providers.produce(new ResteasyJaxrsProviderBuildItem(EagerSecurityFilter.class.getName())); additionalBeanBuildItem.produce(AdditionalBeanBuildItem.unremovableOf(EagerSecurityFilter.class)); } @@ -163,4 +173,83 @@ void addReactiveRoutesExceptionMapper(List routeDescr } recorder.setReactiveRoutes(reactiveRoutes); } + + @BuildStep + void collectEagerSecurityInterceptorEndpoints(Capabilities capabilities, + CombinedIndexBuildItem index, + BeanRegistrationPhaseBuildItem beanRegistrationPhase, + List bindingBuildItems, + ResteasyDeploymentBuildItem resteasyDeployment, + BuildProducer interceptedMethodsProducer) { + if (capabilities.isPresent(Capability.SECURITY) && !bindingBuildItems.isEmpty() && resteasyDeployment != null) { + AnnotationStore annotationStore = beanRegistrationPhase.getContext().get(BuildExtension.Key.ANNOTATION_STORE); + List resourceClasses = resteasyDeployment.getDeployment().getScannedResourceClasses(); + for (var bindingBuildItem : bindingBuildItems) { + // collect endpoints annotated with this annotation, or of resources annotated with this annotation + DotName annotationName = bindingBuildItem.getAnnotationBinding(); + Map> bindingValueToInterceptedMethods = new HashMap<>(); + for (String className : resourceClasses) { + ClassInfo classInfo = index.getIndex().getClassByName(className); + if (classInfo == null) + throw new IllegalStateException("Unable to find class info for " + className); + // add annotated class endpoints as well as parent class annotated endpoints + // and endpoints of annotated resources + addAllEndpoints(index, classInfo, annotationName, bindingValueToInterceptedMethods, annotationStore); + } + if (!bindingValueToInterceptedMethods.isEmpty()) { + interceptedMethodsProducer.produce( + new EagerSecurityInterceptorMethodsBuildItem(bindingValueToInterceptedMethods, annotationName)); + } + } + } + } + + private static void addInterceptedEndpoint(MethodInfo classEndpoint, AnnotationInstance annotationInstance, + DotName annotation, Map> bindingValueToInterceptedMethods) { + if (annotationInstance.value() == null || annotationInstance.value().asString().isBlank()) { + throw new ConfigurationException("Annotation '" + annotation + "' placed on '" + + toTargetName(classEndpoint) + "' must not have blank value"); + } + bindingValueToInterceptedMethods + .computeIfAbsent(annotationInstance.value().asString(), s -> new ArrayList<>()) + .add(classEndpoint); + } + + private static String toTargetName(AnnotationTarget target) { + if (target.kind() == AnnotationTarget.Kind.METHOD) { + return target.asMethod().declaringClass().name().toString() + "#" + target.asMethod().name(); + } else { + return target.asClass().name().toString(); + } + } + + private static void addAllEndpoints(CombinedIndexBuildItem index, ClassInfo classInfo, DotName annotationName, + Map> bindingValueToInterceptedMethods, AnnotationStore annotationStore) { + if (classInfo == null) { + return; + } + addEndpoints(index, classInfo, annotationName, bindingValueToInterceptedMethods, annotationStore); + if (classInfo.superClassType() != null && !classInfo.superClassType().name().equals(DotName.OBJECT_NAME)) { + // add parent class endpoints + addAllEndpoints(index, index.getIndex().getClassByName(classInfo.superClassType().name()), annotationName, + bindingValueToInterceptedMethods, annotationStore); + } + } + + private static void addEndpoints(CombinedIndexBuildItem index, ClassInfo classInfo, DotName annotationName, + Map> bindingValueToInterceptedMethods, AnnotationStore annotationStore) { + final boolean hasClassLevelAnnotation = annotationStore.hasAnnotation(classInfo, annotationName); + for (MethodInfo methodInfo : classInfo.methods()) { + if (isRestEndpointMethod(index, methodInfo, annotationStore)) { + if (annotationStore.hasAnnotation(methodInfo, annotationName)) { + addInterceptedEndpoint(methodInfo, annotationStore.getAnnotation(methodInfo, annotationName), + annotationName, bindingValueToInterceptedMethods); + } else if (hasClassLevelAnnotation) { + // add endpoints that are not annotated themselves + addInterceptedEndpoint(methodInfo, annotationStore.getAnnotation(classInfo, annotationName), annotationName, + bindingValueToInterceptedMethods); + } + } + } + } } diff --git a/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/AnnotationBasedAuthMechanismSelectionTest.java b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/AnnotationBasedAuthMechanismSelectionTest.java new file mode 100644 index 00000000000000..7ae66ea3d68354 --- /dev/null +++ b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/AnnotationBasedAuthMechanismSelectionTest.java @@ -0,0 +1,329 @@ +package io.quarkus.resteasy.test.security; + +import static io.quarkus.resteasy.test.security.AuthMechRequest.requestWithBasicAuthUser; +import static io.quarkus.resteasy.test.security.AuthMechRequest.requestWithFormAuth; +import static io.quarkus.vertx.http.runtime.security.HttpCredentialTransport.Type.AUTHORIZATION; +import static io.restassured.RestAssured.given; +import static org.hamcrest.CoreMatchers.is; + +import java.util.List; +import java.util.Set; +import java.util.stream.IntStream; + +import jakarta.annotation.security.DenyAll; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import io.quarkus.security.Authenticated; +import io.quarkus.security.identity.IdentityProviderManager; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.request.AuthenticationRequest; +import io.quarkus.security.test.utils.TestIdentityController; +import io.quarkus.security.test.utils.TestIdentityProvider; +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.vertx.http.runtime.security.Basic; +import io.quarkus.vertx.http.runtime.security.BasicAuthenticationMechanism; +import io.quarkus.vertx.http.runtime.security.ChallengeData; +import io.quarkus.vertx.http.runtime.security.Form; +import io.quarkus.vertx.http.runtime.security.HttpAuthMechanism; +import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism; +import io.quarkus.vertx.http.runtime.security.HttpCredentialTransport; +import io.restassured.RestAssured; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; + +public class AnnotationBasedAuthMechanismSelectionTest { + + private static final List REQUESTS = List.of( + new AuthMechRequest("annotated-http-permissions/no-roles-allowed-basic").basic().noRbacAnnotation(), + new AuthMechRequest("unannotated-http-permissions/no-roles-allowed-basic").basic().noRbacAnnotation(), + new AuthMechRequest("annotated-http-permissions/roles-allowed-annotation-basic-auth").basic(), + new AuthMechRequest("unannotated-http-permissions/roles-allowed-annotation-basic-auth").basic(), + new AuthMechRequest("annotated-http-permissions/unauthenticated-form").form().noRbacAnnotation(), + new AuthMechRequest("unannotated-http-permissions/unauthenticated-form").form().noRbacAnnotation(), + new AuthMechRequest("annotated-http-permissions/authenticated-form").form().authRequest(), + new AuthMechRequest("unannotated-http-permissions/authenticated-form").form().authRequest(), + new AuthMechRequest("annotated-http-permissions/custom-inherited").custom(), + new AuthMechRequest("annotated-http-permissions/basic-inherited").basic().authRequest(), + new AuthMechRequest("annotated-http-permissions/form-default").form().defaultAuthMech().authRequest(), + new AuthMechRequest("annotated-http-permissions/custom").custom().noRbacAnnotation(), + new AuthMechRequest("annotated-http-permissions/custom-roles-allowed").custom(), + new AuthMechRequest("unannotated-http-permissions/deny-custom").custom().denyPolicy()); + + @RegisterExtension + static QuarkusUnitTest runner = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(TestIdentityProvider.class, TestIdentityController.class, + CustomBasicAuthMechanism.class, AbstractHttpPermissionsResource.class, + AnnotatedHttpPermissionsResource.class, AbstractAnnotatedHttpPermissionsResource.class, + UnannotatedHttpPermissionsResource.class, AuthMechRequest.class, + TestTrustedIdentityProvider.class) + .addAsResource(new StringAsset("" + + "quarkus.http.auth.proactive=false\n" + + "quarkus.http.auth.form.enabled=true\n" + + "quarkus.http.auth.form.login-page=\n" + + "quarkus.http.auth.form.error-page=\n" + + "quarkus.http.auth.form.landing-page=\n" + + "quarkus.http.auth.basic=true\n" + + "quarkus.http.auth.permission.roles1.paths=/annotated-http-permissions/roles-allowed," + + "/unannotated-http-permissions/roles-allowed\n" + + "quarkus.http.auth.permission.roles1.policy=roles1\n" + + "quarkus.http.auth.policy.roles1.roles-allowed=admin\n" + + "quarkus.http.auth.permission.authenticated.paths=/annotated-http-permissions/authenticated," + + "/unannotated-http-permissions/authenticated\n" + + "quarkus.http.auth.permission.authenticated.policy=authenticated\n" + + "quarkus.http.auth.permission.permit1.paths=/annotated-http-permissions/permit," + + "/unannotated-http-permissions/permit\n" + + "quarkus.http.auth.permission.permit1.policy=permit\n" + + "quarkus.http.auth.permission.deny1.paths=/annotated-http-permissions/deny," + + "/unannotated-http-permissions/deny\n" + + "quarkus.http.auth.permission.deny1.policy=deny\n"), "application.properties")); + + @BeforeAll + public static void setupUsers() { + TestIdentityController.resetRoles() + .add("admin", "admin", "admin") + .add("user", "user", "user"); + RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); + } + + @MethodSource("authMechanismRequestsIdxs") + @ParameterizedTest + public void testAuthMechanismSelection(final int idx) { + var in = REQUESTS.get(idx); + in.requestSpecification.get() + .get(in.path) + .then() + .statusCode(in.expectedStatus) + .body(is(in.expectedBody)) + .header(in.expectedHeaderKey, in.expectedHeaderVal); + if (in.authRequired && in.unauthorizedRequestSpec != null) { + in.unauthorizedRequestSpec.get().get(in.path).then().statusCode(403).header(in.expectedHeaderKey, + in.expectedHeaderVal); + } + if (in.authRequired && in.unauthenticatedRequestSpec != null) { + in.unauthenticatedRequestSpec.get().get(in.path).then().statusCode(401).header(in.expectedHeaderKey, + in.expectedHeaderVal); + } + if (in.requestUsingOtherAuthMech != null) { + if (in.authRequired) { + in.requestUsingOtherAuthMech.get().get(in.path).then().statusCode(401).header(in.expectedHeaderKey, + in.expectedHeaderVal); + } else { + // anonymous request - principal name is empty + in.requestUsingOtherAuthMech.get().get(in.path).then().header(in.expectedHeaderKey, + in.expectedHeaderVal).body(is("")); + } + } + } + + @Test + public void testHttpPolicyApplied() { + given().get("/annotated-http-permissions/authenticated").then().statusCode(401); + given().get("/unannotated-http-permissions/authenticated").then().statusCode(401); + given().get("/annotated-http-permissions/deny").then().statusCode(401); + given().get("/unannotated-http-permissions/deny").then().statusCode(401); + // both basic and form auth mechanism can be used even though the resource is annotated with 'form' + // because HTTP policies are applied before the mechanism is selected + requestWithBasicAuthUser().get("/annotated-http-permissions/roles-allowed").then().statusCode(403); + requestWithFormAuth("user").get("/unannotated-http-permissions/roles-allowed").then().statusCode(403); + + // works because no authentication is performed by HTTP permissions policy 'permit', but for @Form is applied + // @Authenticated by default + given().get("/annotated-http-permissions/permit").then().statusCode(401); + given().get("/unannotated-http-permissions/permit").then().statusCode(401); + + // status is 401 even though credentials are correct - that is because 'authenticated' policy requires + // authentication and if it happens before the mechanism is selected, we can't guarantee correct identity + requestWithBasicAuthUser().get("/annotated-http-permissions/authenticated").then().statusCode(401); + requestWithFormAuth("user").get("/unannotated-http-permissions/authenticated").then().statusCode(401); + requestWithFormAuth("admin").get("/annotated-http-permissions/roles-allowed").then().statusCode(401); + requestWithFormAuth("admin").get("/unannotated-http-permissions/roles-allowed").then().statusCode(401); + } + + private static IntStream authMechanismRequestsIdxs() { + return IntStream.range(0, REQUESTS.size()); + } + + @Path("unannotated-http-permissions") + public static class UnannotatedHttpPermissionsResource extends AbstractHttpPermissionsResource { + + @HttpAuthMechanism("custom") + @DenyAll + @Path("deny-custom") + @GET + public String denyCustomAuthMechanism() { + // verifies custom auth mechanism is applied when authenticated requests comes in (by 403 and custom headers) + return "ignored"; + } + } + + public static class AbstractAnnotatedHttpPermissionsResource extends AbstractHttpPermissionsResource { + + @RolesAllowed("admin") + @HttpAuthMechanism("custom") + @Path("custom-roles-allowed") + @GET + public String noPolicyCustomAuthMechRolesAllowed() { + // verifies method-level annotation is used and for basic credentials, custom auth mechanism is applied + return "custom-roles-allowed"; + } + + @HttpAuthMechanism("custom") + @Path("custom") + @GET + public String noPolicyCustomAuthMech() { + // verifies method-level annotation is used and for basic credentials, custom auth mechanism is applied + // even when no RBAC annotation is present + return securityIdentity.getPrincipal().getName(); + } + + @Authenticated + @Path("form-default") + @GET + public String formDefault() { + // verifies when no @HttpAuthMechanism is applied, default form authentication is used + // also verifies @HttpAuthMechanism on abstract class is not applied + return "form-default"; + } + + } + + @HttpAuthMechanism("custom") // verifies that + @Path("annotated-http-permissions") + public static class AnnotatedHttpPermissionsResource extends AbstractAnnotatedHttpPermissionsResource { + + @Authenticated + @Basic + @Path("basic-inherited") + @GET + public String basicInherited() { + // verifies method-level annotation has priority over inherited class-level annotation + return "basic-inherited"; + } + + @RolesAllowed("admin") + @Path("custom-inherited") + @GET + public String customInherited() { + // verifies class-level annotation is applied, not inherited form authentication from abstract class + return "custom-inherited"; + } + } + + @Form + public static abstract class AbstractHttpPermissionsResource { + + @Inject + SecurityIdentity securityIdentity; + + @Path("permit") + @GET + public String permit() { + return "permit"; + } + + @Path("deny") + @GET + public String deny() { + return "deny"; + } + + @Path("roles-allowed") + @GET + public String rolesAllowed() { + return "roles-allowed"; + } + + @Path("authenticated") + @GET + public String authenticated() { + return "authenticated"; + } + + @Authenticated + @Path("authenticated-form") + @GET + public String authenticatedNoPolicyFormAuthMech() { + // verifies class-level annotation declared on this class is applied when RBAC annotation is present + return "authenticated-form"; + } + + @Path("unauthenticated-form") + @GET + public String unauthenticatedNoPolicyFormAuthMech() { + // verifies class-level annotation declared on this class is applied when no RBAC annotation is present + return securityIdentity.getPrincipal().getName(); + } + + @RolesAllowed("admin") + @Basic + @Path("roles-allowed-annotation-basic-auth") + @GET + public String rolesAllowedNoPolicyBasicAuthMech() { + // verifies method-level annotation has priority over class-level annotation on same class + return "roles-allowed-annotation-basic-auth"; + } + + @Basic + @Path("no-roles-allowed-basic") + @GET + public String noPolicyBasicAuthMech() { + // verifies method-level annotation has priority over class-level even when no RBAC annotation is present + return securityIdentity.getPrincipal().getName(); + } + } + + @Singleton + public static class CustomBasicAuthMechanism implements HttpAuthenticationMechanism { + + static final String CUSTOM_AUTH_HEADER_KEY = CustomBasicAuthMechanism.class.getName(); + + private final BasicAuthenticationMechanism delegate; + + public CustomBasicAuthMechanism(BasicAuthenticationMechanism delegate) { + this.delegate = delegate; + } + + @Override + public Uni authenticate(RoutingContext context, IdentityProviderManager identityProviderManager) { + context.response().putHeader(CUSTOM_AUTH_HEADER_KEY, "true"); + return delegate.authenticate(context, identityProviderManager); + } + + @Override + public Uni getChallenge(RoutingContext context) { + return delegate.getChallenge(context); + } + + @Override + public Set> getCredentialTypes() { + return delegate.getCredentialTypes(); + } + + @Override + public Uni sendChallenge(RoutingContext context) { + return delegate.sendChallenge(context); + } + + @Override + public Uni getCredentialTransport(RoutingContext context) { + return Uni.createFrom().item(new HttpCredentialTransport(AUTHORIZATION, "custom")); + } + + @Override + public int getPriority() { + return delegate.getPriority(); + } + } +} diff --git a/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/AuthMechRequest.java b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/AuthMechRequest.java new file mode 100644 index 00000000000000..f8b72e64e88311 --- /dev/null +++ b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/AuthMechRequest.java @@ -0,0 +1,121 @@ +package io.quarkus.resteasy.test.security; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; + +import java.util.function.Supplier; + +import org.hamcrest.Matcher; +import org.hamcrest.Matchers; + +import io.restassured.RestAssured; +import io.restassured.filter.cookie.CookieFilter; +import io.restassured.matcher.RestAssuredMatchers; +import io.restassured.specification.RequestSpecification; + +public class AuthMechRequest { + + final String path; + final String expectedHeaderKey; + String expectedBody; + Matcher expectedHeaderVal; + int expectedStatus; + boolean authRequired; + Supplier requestSpecification; + Supplier unauthorizedRequestSpec; + Supplier unauthenticatedRequestSpec = RestAssured::given; + Supplier requestUsingOtherAuthMech; + + public AuthMechRequest(String path) { + this.path = path; + this.expectedHeaderKey = AnnotationBasedAuthMechanismSelectionTest.CustomBasicAuthMechanism.CUSTOM_AUTH_HEADER_KEY; + expectedBody = path.substring(path.lastIndexOf('/') + 1); + expectedStatus = 200; + authRequired = true; + } + + AuthMechRequest basic() { + requestSpecification = AuthMechRequest::requestWithBasicAuth; + unauthorizedRequestSpec = AuthMechRequest::requestWithBasicAuthUser; + requestUsingOtherAuthMech = () -> requestWithFormAuth("admin"); + expectedHeaderVal = nullValue(); + return this; + } + + AuthMechRequest custom() { + basic(); + expectedHeaderVal = notNullValue(); + return this; + } + + AuthMechRequest noRbacAnnotation() { + // no RBAC annotation == @Authenticated + // response contains security identity principal name to verify authenticated sec. identity + authRequest(); + expectedBody = "admin"; + return this; + } + + AuthMechRequest defaultAuthMech() { + // when we do not explicitly select auth mechanism, even custom auth mechanism is invoked, but no + // Authorization header is present, so it's not used in the end + expectedHeaderVal = Matchers.anything(); + // naturally, all mechanisms are going to be accepted + requestUsingOtherAuthMech = null; + return this; + } + + AuthMechRequest denyPolicy() { + expectedStatus = 403; + expectedBody = ""; + return this; + } + + AuthMechRequest authRequest() { + // endpoint annotated with @Authenticated will not check roles, so no authZ + unauthorizedRequestSpec = null; + return this; + } + + AuthMechRequest pathAnnotationDeclaredOnInterface() { + // RBAC annotations on interfaces are ignored + authRequired = false; + return this; + } + + AuthMechRequest form() { + requestSpecification = () -> requestWithFormAuth("admin"); + unauthorizedRequestSpec = () -> requestWithFormAuth("user"); + requestUsingOtherAuthMech = AuthMechRequest::requestWithBasicAuth; + expectedHeaderVal = nullValue(); + return this; + } + + static RequestSpecification requestWithBasicAuth() { + return given().auth().preemptive().basic("admin", "admin"); + } + + static RequestSpecification requestWithFormAuth(String user) { + CookieFilter cookies = new CookieFilter(); + RestAssured + .given() + .filter(cookies) + .when() + .formParam("j_username", user) + .formParam("j_password", user) + .post("/j_security_check") + .then() + .assertThat() + .statusCode(200) + .cookie("quarkus-credential", + RestAssuredMatchers.detailedCookie().value(notNullValue()).secured(false)); + return RestAssured + .given() + .filter(cookies); + } + + static RequestSpecification requestWithBasicAuthUser() { + return given().auth().preemptive().basic("user", "user"); + } +} diff --git a/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/TestTrustedIdentityProvider.java b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/TestTrustedIdentityProvider.java new file mode 100644 index 00000000000000..56a21dc1f20df9 --- /dev/null +++ b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/TestTrustedIdentityProvider.java @@ -0,0 +1,40 @@ +package io.quarkus.resteasy.test.security; + +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +import jakarta.inject.Singleton; + +import io.quarkus.security.AuthenticationFailedException; +import io.quarkus.security.identity.AuthenticationRequestContext; +import io.quarkus.security.identity.IdentityProvider; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.request.TrustedAuthenticationRequest; +import io.quarkus.security.runtime.QuarkusPrincipal; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.quarkus.security.test.utils.TestIdentityController; +import io.quarkus.vertx.http.runtime.security.HttpSecurityUtils; +import io.smallrye.mutiny.Uni; + +@Singleton +public class TestTrustedIdentityProvider implements IdentityProvider { + @Override + public Class getRequestType() { + return TrustedAuthenticationRequest.class; + } + + @Override + public Uni authenticate(TrustedAuthenticationRequest request, + AuthenticationRequestContext context) { + if (HttpSecurityUtils.getRoutingContextAttribute(request) == null) { + return Uni.createFrom().failure(new AuthenticationFailedException()); + } + TestIdentityController.TestIdentity ident = TestIdentityController.identities.get(request.getPrincipal()); + if (ident == null) { + return Uni.createFrom().optional(Optional.empty()); + } + return Uni.createFrom().completionStage(CompletableFuture + .completedFuture(QuarkusSecurityIdentity.builder().setPrincipal(new QuarkusPrincipal(request.getPrincipal())) + .addRoles(ident.roles).build())); + } +} diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveProcessor.java b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveProcessor.java index 2b018c4247b585..9734709545d949 100644 --- a/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveProcessor.java +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveProcessor.java @@ -4,6 +4,7 @@ import static io.quarkus.resteasy.reactive.common.deployment.QuarkusResteasyReactiveDotNames.HTTP_SERVER_RESPONSE; import static io.quarkus.resteasy.reactive.common.deployment.QuarkusResteasyReactiveDotNames.ROUTING_CONTEXT; import static java.util.stream.Collectors.toList; +import static org.jboss.resteasy.reactive.common.processor.EndpointIndexer.collectEndpoints; import static org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames.DATE_FORMAT; import static org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames.LEGACY_PUBLISHER; import static org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames.MULTI; @@ -124,8 +125,11 @@ import io.quarkus.arc.deployment.AdditionalBeanBuildItem; import io.quarkus.arc.deployment.BeanArchiveIndexBuildItem; import io.quarkus.arc.deployment.BeanContainerBuildItem; +import io.quarkus.arc.deployment.BeanRegistrationPhaseBuildItem; import io.quarkus.arc.deployment.GeneratedBeanBuildItem; import io.quarkus.arc.deployment.UnremovableBeanBuildItem; +import io.quarkus.arc.processor.AnnotationStore; +import io.quarkus.arc.processor.BuildExtension; import io.quarkus.arc.runtime.BeanContainer; import io.quarkus.deployment.Capabilities; import io.quarkus.deployment.Capability; @@ -195,11 +199,13 @@ import io.quarkus.resteasy.reactive.spi.MessageBodyWriterOverrideBuildItem; import io.quarkus.runtime.LaunchMode; import io.quarkus.runtime.RuntimeValue; +import io.quarkus.runtime.configuration.ConfigurationException; import io.quarkus.security.AuthenticationCompletionException; import io.quarkus.security.AuthenticationFailedException; import io.quarkus.security.AuthenticationRedirectException; import io.quarkus.security.ForbiddenException; -import io.quarkus.vertx.http.deployment.EagerSecurityInterceptorBuildItem; +import io.quarkus.vertx.http.deployment.EagerSecurityInterceptorBindingBuildItem; +import io.quarkus.vertx.http.deployment.EagerSecurityInterceptorMethodsBuildItem; import io.quarkus.vertx.http.deployment.FilterBuildItem; import io.quarkus.vertx.http.deployment.RouteBuildItem; import io.quarkus.vertx.http.runtime.HttpBuildTimeConfig; @@ -1489,12 +1495,23 @@ public void securityExceptionMappers(BuildProducer exc @BuildStep MethodScannerBuildItem integrateEagerSecurity(Capabilities capabilities, CombinedIndexBuildItem indexBuildItem, - HttpBuildTimeConfig httpBuildTimeConfig, Optional eagerSecurityInterceptors) { + HttpBuildTimeConfig httpBuildTimeConfig, + List eagerSecurityInterceptors) { if (!capabilities.isPresent(Capability.SECURITY)) { return null; } - final boolean applySecurityInterceptors = eagerSecurityInterceptors.isPresent(); + final boolean applySecurityInterceptors = !eagerSecurityInterceptors.isEmpty(); + final Set securityInterceptorMethods; + if (applySecurityInterceptors) { + securityInterceptorMethods = eagerSecurityInterceptors + .stream() + .map(EagerSecurityInterceptorMethodsBuildItem::interceptedMethods) + .flatMap(Collection::stream) + .collect(Collectors.toSet()); + } else { + securityInterceptorMethods = null; + } final boolean denyJaxRs = ConfigProvider.getConfig() .getOptionalValue("quarkus.security.jaxrs.deny-unannotated-endpoints", Boolean.class).orElse(false); final boolean hasDefaultJaxRsRolesAllowed = ConfigProvider.getConfig() @@ -1513,7 +1530,7 @@ public List scan(MethodInfo method, ClassInfo actualEndp securityHandlerList = Collections .singletonList(EagerSecurityHandler.Customizer.newInstance(httpBuildTimeConfig.auth.proactive)); } - if (applySecurityInterceptors && eagerSecurityInterceptors.get().applyInterceptorOn(method)) { + if (applySecurityInterceptors && securityInterceptorMethods.contains(method)) { List nextSecurityHandlerList = new ArrayList<>(); nextSecurityHandlerList.add(EagerSecurityInterceptorHandler.Customizer.newInstance()); @@ -1590,6 +1607,77 @@ void registerSecurityInterceptors(Capabilities capabilities, } } + @BuildStep + void collectEagerSecurityInterceptorEndpoints(Capabilities capabilities, + CombinedIndexBuildItem index, + BeanRegistrationPhaseBuildItem beanRegistrationPhase, + List bindingBuildItems, + BeanArchiveIndexBuildItem beanArchiveIndexBuildItem, + ApplicationResultBuildItem applicationResultBuildItem, + List annotationsTransformerBuildItems, + BuildProducer interceptedMethodsProducer, + Optional resteasyDeployment) { + if (capabilities.isPresent(Capability.SECURITY) && !bindingBuildItems.isEmpty() && resteasyDeployment.isPresent()) { + AnnotationStore annotationStore = beanRegistrationPhase.getContext().get(BuildExtension.Key.ANNOTATION_STORE); + final org.jboss.resteasy.reactive.common.processor.transformation.AnnotationStore rrAnnotationStore; + if (annotationsTransformerBuildItems.isEmpty()) { + rrAnnotationStore = new org.jboss.resteasy.reactive.common.processor.transformation.AnnotationStore(null); + } else { + rrAnnotationStore = new org.jboss.resteasy.reactive.common.processor.transformation.AnnotationStore( + annotationsTransformerBuildItems.stream() + .map(AnnotationsTransformerBuildItem::getAnnotationsTransformer).collect(Collectors.toList())); + } + Map httpAnnotationToMethod = resteasyDeployment.get().getResult().getHttpAnnotationToMethod(); + Set resourceClasses = resteasyDeployment.get().getResult().getScannedResourcePaths().keySet(); + for (EagerSecurityInterceptorBindingBuildItem bindingBuildItem : bindingBuildItems) { + DotName annotationName = bindingBuildItem.getAnnotationBinding(); + Map> bindingValueToInterceptedMethods = new HashMap<>(); + for (DotName className : resourceClasses) { + ClassInfo classInfo = index.getIndex().getClassByName(className); + if (classInfo == null) + throw new IllegalStateException("Unable to find class info for " + className); + // collect class endpoints + collectEndpoints(classInfo, httpAnnotationToMethod, beanArchiveIndexBuildItem.getIndex(), + applicationResultBuildItem.getResult(), rrAnnotationStore).forEach( + (endpoint, actualClass) -> { + if (annotationStore.hasAnnotation(endpoint, annotationName)) { + addInterceptedEndpoint(endpoint, + annotationStore.getAnnotation(endpoint, annotationName), + annotationName, bindingValueToInterceptedMethods); + } else if (annotationStore.hasAnnotation(actualClass, annotationName)) { + addInterceptedEndpoint(endpoint, + annotationStore.getAnnotation(actualClass, annotationName), annotationName, + bindingValueToInterceptedMethods); + } + }); + } + if (!bindingValueToInterceptedMethods.isEmpty()) { + interceptedMethodsProducer.produce( + new EagerSecurityInterceptorMethodsBuildItem(bindingValueToInterceptedMethods, annotationName)); + } + } + } + } + + private static void addInterceptedEndpoint(MethodInfo classEndpoint, AnnotationInstance annotationInstance, + DotName annotation, Map> bindingValueToInterceptedMethods) { + if (annotationInstance.value() == null || annotationInstance.value().asString().isBlank()) { + throw new ConfigurationException("Annotation '" + annotation + "' placed on '" + + toTargetName(classEndpoint) + "' must not have blank value"); + } + bindingValueToInterceptedMethods + .computeIfAbsent(annotationInstance.value().asString(), s -> new ArrayList<>()) + .add(classEndpoint); + } + + private static String toTargetName(AnnotationTarget target) { + if (target.kind() == AnnotationTarget.Kind.METHOD) { + return target.asMethod().declaringClass().name().toString() + "#" + target.asMethod().name(); + } else { + return target.asClass().name().toString(); + } + } + private T consumeStandardSecurityAnnotations(MethodInfo methodInfo, ClassInfo classInfo, IndexView index, Function function) { if (SecurityTransformerUtils.hasStandardSecurityAnnotation(methodInfo)) { diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/AnnotationBasedAuthMechanismSelectionTest.java b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/AnnotationBasedAuthMechanismSelectionTest.java new file mode 100644 index 00000000000000..6f2aa8c92c905a --- /dev/null +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/AnnotationBasedAuthMechanismSelectionTest.java @@ -0,0 +1,424 @@ +package io.quarkus.resteasy.reactive.server.test.security; + +import static io.quarkus.resteasy.reactive.server.test.security.AuthMechRequest.requestWithBasicAuthUser; +import static io.quarkus.resteasy.reactive.server.test.security.AuthMechRequest.requestWithFormAuth; +import static io.quarkus.vertx.http.runtime.security.HttpCredentialTransport.Type.AUTHORIZATION; +import static io.restassured.RestAssured.given; +import static org.hamcrest.CoreMatchers.is; + +import java.util.List; +import java.util.Set; +import java.util.stream.IntStream; + +import jakarta.annotation.security.DenyAll; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import io.quarkus.arc.Arc; +import io.quarkus.security.Authenticated; +import io.quarkus.security.identity.IdentityProviderManager; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.request.AuthenticationRequest; +import io.quarkus.security.test.utils.TestIdentityController; +import io.quarkus.security.test.utils.TestIdentityProvider; +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.vertx.http.runtime.security.Basic; +import io.quarkus.vertx.http.runtime.security.BasicAuthenticationMechanism; +import io.quarkus.vertx.http.runtime.security.ChallengeData; +import io.quarkus.vertx.http.runtime.security.Form; +import io.quarkus.vertx.http.runtime.security.HttpAuthMechanism; +import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism; +import io.quarkus.vertx.http.runtime.security.HttpCredentialTransport; +import io.restassured.RestAssured; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; + +public class AnnotationBasedAuthMechanismSelectionTest { + + private static final List REQUESTS = List.of( + new AuthMechRequest("annotated-http-permissions/no-roles-allowed-basic").basic().noRbacAnnotation(), + new AuthMechRequest("unannotated-http-permissions/no-roles-allowed-basic").basic().noRbacAnnotation(), + new AuthMechRequest("annotated-http-permissions/roles-allowed-annotation-basic-auth").basic(), + new AuthMechRequest("unannotated-http-permissions/roles-allowed-annotation-basic-auth").basic(), + new AuthMechRequest("annotated-http-permissions/unauthenticated-form").form().noRbacAnnotation(), + new AuthMechRequest("unannotated-http-permissions/unauthenticated-form").form().noRbacAnnotation(), + new AuthMechRequest("annotated-http-permissions/authenticated-form").form().authRequest(), + new AuthMechRequest("unannotated-http-permissions/authenticated-form").form().authRequest(), + new AuthMechRequest("unannotated-http-permissions/basic-class-level-interface").basic().noRbacAnnotation() + .pathAnnotationDeclaredOnInterface(), + new AuthMechRequest("annotated-http-permissions/basic-class-level-interface").basic().noRbacAnnotation() + .pathAnnotationDeclaredOnInterface(), + new AuthMechRequest("annotated-http-permissions/overridden-parent-class-endpoint").custom().noRbacAnnotation(), + new AuthMechRequest("annotated-http-permissions/default-impl-custom-class-level-interface").custom() + .noRbacAnnotation(), + new AuthMechRequest("unannotated-http-permissions/overridden-parent-class-endpoint").form().noRbacAnnotation(), + new AuthMechRequest("unannotated-http-permissions/default-impl-custom-class-level-interface").basic() + .noRbacAnnotation().pathAnnotationDeclaredOnInterface(), + new AuthMechRequest("annotated-http-permissions/default-form-method-level-interface").form().noRbacAnnotation() + .defaultAuthMech(), + new AuthMechRequest("unannotated-http-permissions/default-form-method-level-interface").form().noRbacAnnotation() + .defaultAuthMech(), + new AuthMechRequest("annotated-http-permissions/basic-method-level-interface").basic().noRbacAnnotation() + .defaultAuthMech(), + new AuthMechRequest("unannotated-http-permissions/basic-method-level-interface").basic().noRbacAnnotation() + .defaultAuthMech(), + new AuthMechRequest("annotated-http-permissions/custom-inherited").custom(), + new AuthMechRequest("annotated-http-permissions/basic-inherited").basic().authRequest(), + new AuthMechRequest("annotated-http-permissions/form-default").form().defaultAuthMech().authRequest(), + new AuthMechRequest("annotated-http-permissions/custom").custom().noRbacAnnotation(), + new AuthMechRequest("annotated-http-permissions/custom-roles-allowed").custom(), + new AuthMechRequest("unannotated-http-permissions/deny-custom").custom().denyPolicy()); + + @RegisterExtension + static QuarkusUnitTest runner = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(TestIdentityProvider.class, TestIdentityController.class, + CustomBasicAuthMechanism.class, AbstractHttpPermissionsResource.class, + AnnotatedHttpPermissionsResource.class, AbstractAnnotatedHttpPermissionsResource.class, + UnannotatedHttpPermissionsResource.class, HttpPermissionsResourceClassLevelInterface.class, + HttpPermissionsResourceMethodLevelInterface.class, AuthMechRequest.class, + TestTrustedIdentityProvider.class) + .addAsResource(new StringAsset("" + + "quarkus.http.auth.proactive=false\n" + + "quarkus.http.auth.form.enabled=true\n" + + "quarkus.http.auth.form.login-page=\n" + + "quarkus.http.auth.form.error-page=\n" + + "quarkus.http.auth.form.landing-page=\n" + + "quarkus.http.auth.basic=true\n" + + "quarkus.http.auth.permission.roles1.paths=/annotated-http-permissions/roles-allowed," + + "/unannotated-http-permissions/roles-allowed\n" + + "quarkus.http.auth.permission.roles1.policy=roles1\n" + + "quarkus.http.auth.policy.roles1.roles-allowed=admin\n" + + "quarkus.http.auth.permission.authenticated.paths=/annotated-http-permissions/authenticated," + + "/unannotated-http-permissions/authenticated\n" + + "quarkus.http.auth.permission.authenticated.policy=authenticated\n" + + "quarkus.http.auth.permission.permit1.paths=/annotated-http-permissions/permit," + + "/unannotated-http-permissions/permit\n" + + "quarkus.http.auth.permission.permit1.policy=permit\n" + + "quarkus.http.auth.permission.deny1.paths=/annotated-http-permissions/deny," + + "/unannotated-http-permissions/deny\n" + + "quarkus.http.auth.permission.deny1.policy=deny\n"), "application.properties")); + + @BeforeAll + public static void setupUsers() { + TestIdentityController.resetRoles() + .add("admin", "admin", "admin") + .add("user", "user", "user"); + RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); + } + + @MethodSource("authMechanismRequestsIdxs") + @ParameterizedTest + public void testAuthMechanismSelection(final int idx) { + var in = REQUESTS.get(idx); + in.requestSpecification.get() + .get(in.path) + .then() + .statusCode(in.expectedStatus) + .body(is(in.expectedBody)) + .header(in.expectedHeaderKey, in.expectedHeaderVal); + if (in.authRequired && in.unauthorizedRequestSpec != null) { + in.unauthorizedRequestSpec.get().get(in.path).then().statusCode(403).header(in.expectedHeaderKey, + in.expectedHeaderVal); + } + if (in.authRequired && in.unauthenticatedRequestSpec != null) { + in.unauthenticatedRequestSpec.get().get(in.path).then().statusCode(401).header(in.expectedHeaderKey, + in.expectedHeaderVal); + } + if (in.requestUsingOtherAuthMech != null) { + if (in.authRequired) { + in.requestUsingOtherAuthMech.get().get(in.path).then().statusCode(401).header(in.expectedHeaderKey, + in.expectedHeaderVal); + } else { + // anonymous request - principal name is empty + in.requestUsingOtherAuthMech.get().get(in.path).then().header(in.expectedHeaderKey, + in.expectedHeaderVal).body(is("")); + } + } + } + + @Test + public void testHttpPolicyApplied() { + given().get("/annotated-http-permissions/authenticated").then().statusCode(401); + given().get("/unannotated-http-permissions/authenticated").then().statusCode(401); + given().get("/annotated-http-permissions/deny").then().statusCode(401); + given().get("/unannotated-http-permissions/deny").then().statusCode(401); + // both basic and form auth mechanism can be used even though the resource is annotated with 'form' + // because HTTP policies are applied before the mechanism is selected + requestWithBasicAuthUser().get("/annotated-http-permissions/roles-allowed").then().statusCode(403); + requestWithFormAuth("user").get("/unannotated-http-permissions/roles-allowed").then().statusCode(403); + + // works because no authentication is performed by HTTP permissions policy 'permit', but for @Form is applied + // @Authenticated by default + given().get("/annotated-http-permissions/permit").then().statusCode(401); + given().get("/unannotated-http-permissions/permit").then().statusCode(401); + + // status is 401 even though credentials are correct - that is because 'authenticated' policy requires + // authentication and if it happens before the mechanism is selected, we can't guarantee correct identity + requestWithBasicAuthUser().get("/annotated-http-permissions/authenticated").then().statusCode(401); + requestWithFormAuth("user").get("/unannotated-http-permissions/authenticated").then().statusCode(401); + requestWithFormAuth("admin").get("/annotated-http-permissions/roles-allowed").then().statusCode(401); + requestWithFormAuth("admin").get("/unannotated-http-permissions/roles-allowed").then().statusCode(401); + } + + private static IntStream authMechanismRequestsIdxs() { + return IntStream.range(0, REQUESTS.size()); + } + + @Path("unannotated-http-permissions") + public static class UnannotatedHttpPermissionsResource extends AbstractHttpPermissionsResource { + + @HttpAuthMechanism("custom") + @DenyAll + @Path("deny-custom") + @GET + public String denyCustomAuthMechanism() { + // verifies custom auth mechanism is applied when authenticated requests comes in (by 403 and custom headers) + return "ignored"; + } + + @Override + public String defaultImplementedClassLevelInterfaceMethod() { + // here we do not repeat Path annotation, therefore this interface auth mechanism is going to be used + return super.defaultImplementedClassLevelInterfaceMethod(); + } + + @Override + public String overriddenParentClassEndpoint() { + // here we do not repeat Path annotation, therefore parent class auth mechanism is going to be used + return super.overriddenParentClassEndpoint(); + } + } + + public static class AbstractAnnotatedHttpPermissionsResource extends AbstractHttpPermissionsResource { + + @RolesAllowed("admin") + @HttpAuthMechanism("custom") + @Path("custom-roles-allowed") + @GET + public String noPolicyCustomAuthMechRolesAllowed() { + // verifies method-level annotation is used and for basic credentials, custom auth mechanism is applied + return "custom-roles-allowed"; + } + + @HttpAuthMechanism("custom") + @Path("custom") + @GET + public String noPolicyCustomAuthMech() { + // verifies method-level annotation is used and for basic credentials, custom auth mechanism is applied + // even when no RBAC annotation is present + return securityIdentity.getPrincipal().getName(); + } + + @Authenticated + @Path("form-default") + @GET + public String formDefault() { + // verifies when no @HttpAuthMechanism is applied, default form authentication is used + // also verifies @HttpAuthMechanism on abstract class is not applied + return "form-default"; + } + + } + + @HttpAuthMechanism("custom") // verifies that + @Path("annotated-http-permissions") + public static class AnnotatedHttpPermissionsResource extends AbstractAnnotatedHttpPermissionsResource { + + @Authenticated + @Basic + @Path("basic-inherited") + @GET + public String basicInherited() { + // verifies method-level annotation has priority over inherited class-level annotation + return "basic-inherited"; + } + + @RolesAllowed("admin") + @Path("custom-inherited") + @GET + public String customInherited() { + // verifies class-level annotation is applied, not inherited form authentication from abstract class + return "custom-inherited"; + } + + @Path("default-impl-custom-class-level-interface") + @GET + @Override + public String defaultImplementedClassLevelInterfaceMethod() { + // here we repeated Path annotation, therefore this class http auth mechanism is going to be used + return super.defaultImplementedClassLevelInterfaceMethod(); + } + + @Path("overridden-parent-class-endpoint") + @GET + @Override + public String overriddenParentClassEndpoint() { + // here we repeated Path annotation, therefore this class http auth mechanism is going to be used + return super.overriddenParentClassEndpoint(); + } + } + + public interface HttpPermissionsResourceMethodLevelInterface { + + @Authenticated // by rules of CDI inheritance, this annotation is completely ignored + @Basic + @Path("basic-method-level-interface") + @GET + default String basicMethodLevelInterface() { + // verifies method-level annotation on default interface method is applied + return Arc.container().instance(SecurityIdentity.class).get().getPrincipal().getName(); + } + + @Authenticated // by rules of CDI inheritance, this annotation is completely ignored + @Path("default-form-method-level-interface") + @GET + default String defaultFormMethodLevelInterface() { + // verifies no specific auth mechanism is enforced unless this method is implemented + return Arc.container().instance(SecurityIdentity.class).get().getPrincipal().getName(); + } + } + + @Basic + public interface HttpPermissionsResourceClassLevelInterface { + + @Path("basic-class-level-interface") + @GET + default String basicClassLevelInterface() { + // verifies class-level annotation is applied on default interface method + return Arc.container().instance(SecurityIdentity.class).get().getPrincipal().getName(); + } + + @Path("default-impl-custom-class-level-interface") + @GET + default String defaultImplementedClassLevelInterfaceMethod() { + // this method will be implemented + return Arc.container().instance(SecurityIdentity.class).get().getPrincipal().getName(); + } + } + + @Form + public static abstract class AbstractHttpPermissionsResource + implements HttpPermissionsResourceClassLevelInterface, HttpPermissionsResourceMethodLevelInterface { + + @Inject + SecurityIdentity securityIdentity; + + @Path("permit") + @GET + public String permit() { + return "permit"; + } + + @Path("deny") + @GET + public String deny() { + return "deny"; + } + + @Path("roles-allowed") + @GET + public String rolesAllowed() { + return "roles-allowed"; + } + + @Path("authenticated") + @GET + public String authenticated() { + return "authenticated"; + } + + @Authenticated + @Path("authenticated-form") + @GET + public String authenticatedNoPolicyFormAuthMech() { + // verifies class-level annotation declared on this class is applied when RBAC annotation is present + return "authenticated-form"; + } + + @Path("unauthenticated-form") + @GET + public String unauthenticatedNoPolicyFormAuthMech() { + // verifies class-level annotation declared on this class is applied when no RBAC annotation is present + return securityIdentity.getPrincipal().getName(); + } + + @RolesAllowed("admin") + @Basic + @Path("roles-allowed-annotation-basic-auth") + @GET + public String rolesAllowedNoPolicyBasicAuthMech() { + // verifies method-level annotation has priority over class-level annotation on same class + return "roles-allowed-annotation-basic-auth"; + } + + @Basic + @Path("no-roles-allowed-basic") + @GET + public String noPolicyBasicAuthMech() { + // verifies method-level annotation has priority over class-level even when no RBAC annotation is present + return securityIdentity.getPrincipal().getName(); + } + + @RolesAllowed("admin") + @Path("overridden-parent-class-endpoint") + @GET + public String overriddenParentClassEndpoint() { + // verifies method-level annotation has priority over class-level even when no RBAC annotation is present + return securityIdentity.getPrincipal().getName(); + } + } + + @Singleton + public static class CustomBasicAuthMechanism implements HttpAuthenticationMechanism { + + static final String CUSTOM_AUTH_HEADER_KEY = CustomBasicAuthMechanism.class.getName(); + + private final BasicAuthenticationMechanism delegate; + + public CustomBasicAuthMechanism(BasicAuthenticationMechanism delegate) { + this.delegate = delegate; + } + + @Override + public Uni authenticate(RoutingContext context, IdentityProviderManager identityProviderManager) { + context.response().putHeader(CUSTOM_AUTH_HEADER_KEY, "true"); + return delegate.authenticate(context, identityProviderManager); + } + + @Override + public Uni getChallenge(RoutingContext context) { + return delegate.getChallenge(context); + } + + @Override + public Set> getCredentialTypes() { + return delegate.getCredentialTypes(); + } + + @Override + public Uni sendChallenge(RoutingContext context) { + return delegate.sendChallenge(context); + } + + @Override + public Uni getCredentialTransport(RoutingContext context) { + return Uni.createFrom().item(new HttpCredentialTransport(AUTHORIZATION, "custom")); + } + + @Override + public int getPriority() { + return delegate.getPriority(); + } + } +} diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/AuthMechRequest.java b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/AuthMechRequest.java new file mode 100644 index 00000000000000..b11f67c7adcd39 --- /dev/null +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/AuthMechRequest.java @@ -0,0 +1,121 @@ +package io.quarkus.resteasy.reactive.server.test.security; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; + +import java.util.function.Supplier; + +import org.hamcrest.Matcher; +import org.hamcrest.Matchers; + +import io.restassured.RestAssured; +import io.restassured.filter.cookie.CookieFilter; +import io.restassured.matcher.RestAssuredMatchers; +import io.restassured.specification.RequestSpecification; + +public class AuthMechRequest { + + final String path; + final String expectedHeaderKey; + String expectedBody; + Matcher expectedHeaderVal; + int expectedStatus; + boolean authRequired; + Supplier requestSpecification; + Supplier unauthorizedRequestSpec; + Supplier unauthenticatedRequestSpec = RestAssured::given; + Supplier requestUsingOtherAuthMech; + + public AuthMechRequest(String path) { + this.path = path; + this.expectedHeaderKey = AnnotationBasedAuthMechanismSelectionTest.CustomBasicAuthMechanism.CUSTOM_AUTH_HEADER_KEY; + expectedBody = path.substring(path.lastIndexOf('/') + 1); + expectedStatus = 200; + authRequired = true; + } + + AuthMechRequest basic() { + requestSpecification = AuthMechRequest::requestWithBasicAuth; + unauthorizedRequestSpec = AuthMechRequest::requestWithBasicAuthUser; + requestUsingOtherAuthMech = () -> requestWithFormAuth("admin"); + expectedHeaderVal = nullValue(); + return this; + } + + AuthMechRequest custom() { + basic(); + expectedHeaderVal = notNullValue(); + return this; + } + + AuthMechRequest noRbacAnnotation() { + // no RBAC annotation == @Authenticated + // response contains security identity principal name to verify authenticated sec. identity + authRequest(); + expectedBody = "admin"; + return this; + } + + AuthMechRequest defaultAuthMech() { + // when we do not explicitly select auth mechanism, even custom auth mechanism is invoked, but no + // Authorization header is present, so it's not used in the end + expectedHeaderVal = Matchers.anything(); + // naturally, all mechanisms are going to be accepted + requestUsingOtherAuthMech = null; + return this; + } + + AuthMechRequest denyPolicy() { + expectedStatus = 403; + expectedBody = ""; + return this; + } + + AuthMechRequest authRequest() { + // endpoint annotated with @Authenticated will not check roles, so no authZ + unauthorizedRequestSpec = null; + return this; + } + + AuthMechRequest pathAnnotationDeclaredOnInterface() { + // RBAC annotations on interfaces are ignored + authRequired = false; + return this; + } + + AuthMechRequest form() { + requestSpecification = () -> requestWithFormAuth("admin"); + unauthorizedRequestSpec = () -> requestWithFormAuth("user"); + requestUsingOtherAuthMech = AuthMechRequest::requestWithBasicAuth; + expectedHeaderVal = nullValue(); + return this; + } + + static RequestSpecification requestWithBasicAuth() { + return given().auth().preemptive().basic("admin", "admin"); + } + + static RequestSpecification requestWithFormAuth(String user) { + CookieFilter cookies = new CookieFilter(); + RestAssured + .given() + .filter(cookies) + .when() + .formParam("j_username", user) + .formParam("j_password", user) + .post("/j_security_check") + .then() + .assertThat() + .statusCode(200) + .cookie("quarkus-credential", + RestAssuredMatchers.detailedCookie().value(notNullValue()).secured(false)); + return RestAssured + .given() + .filter(cookies); + } + + static RequestSpecification requestWithBasicAuthUser() { + return given().auth().preemptive().basic("user", "user"); + } +} diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/MtlsBasicAnnotationBasedAuthMechSelectionTest.java b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/MtlsBasicAnnotationBasedAuthMechSelectionTest.java new file mode 100644 index 00000000000000..96e211c02588d1 --- /dev/null +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/MtlsBasicAnnotationBasedAuthMechSelectionTest.java @@ -0,0 +1,105 @@ +package io.quarkus.resteasy.reactive.server.test.security; + +import static org.hamcrest.Matchers.is; + +import java.io.File; +import java.net.URL; + +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +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.Basic; +import io.quarkus.vertx.http.runtime.security.MTLS; +import io.restassured.RestAssured; + +public class MtlsBasicAnnotationBasedAuthMechSelectionTest { + + @TestHTTPResource(value = "/mtls", ssl = true) + URL mtlsUrl; + + @TestHTTPResource(value = "/basic", ssl = true) + URL basicUrl; + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(MtlsResource.class) + .addClasses(TestIdentityProvider.class, TestTrustedIdentityProvider.class, TestIdentityController.class) + .addAsResource("mtls/mtls-basic-jks.conf", "application.properties") + .addAsResource("mtls/server-keystore.jks", "server-keystore.jks") + .addAsResource("mtls/server-truststore.jks", "server-truststore.jks")); + + @BeforeAll + public static void setup() { + TestIdentityController.resetRoles() + .add("admin", "admin", "admin"); + } + + @Test + public void testMutualTLSAuthenticationEnforced() { + // endpoint is annotated with @MTLS, therefore mTLS must pass while anything less fail + RestAssured.given() + .trustStore(new File("src/test/resources/mtls/client-truststore.jks"), "password") + .get(mtlsUrl).then().statusCode(401); + RestAssured.given() + .keyStore(new File("src/test/resources/mtls/client-keystore.jks"), "password") + .trustStore(new File("src/test/resources/mtls/client-truststore.jks"), "password") + .get(mtlsUrl).then().statusCode(200).body(is("CN=client,OU=cert,O=quarkus,L=city,ST=state,C=AU")); + RestAssured.given() + .auth() + .preemptive() + .basic("admin", "admin") + .trustStore(new File("src/test/resources/mtls/client-truststore.jks"), "password") + .get(mtlsUrl).then().statusCode(401); + } + + @Test + public void testBasicAuthenticationEnforced() { + // endpoint is annotated with @Basic, therefore basic auth must pass while anything less fail + RestAssured.given() + .trustStore(new File("src/test/resources/mtls/client-truststore.jks"), "password") + .get(basicUrl).then().statusCode(401); + RestAssured.given() + .auth() + .preemptive() + .basic("admin", "admin") + .trustStore(new File("src/test/resources/mtls/client-truststore.jks"), "password") + .get(basicUrl).then().statusCode(200).body(is("admin")); + RestAssured.given() + .keyStore(new File("src/test/resources/mtls/client-keystore.jks"), "password") + .trustStore(new File("src/test/resources/mtls/client-truststore.jks"), "password") + .get(basicUrl).then().statusCode(401); + } + + @Path("/") + public static class MtlsResource { + + @Inject + SecurityIdentity identity; + + @MTLS + @Path("mtls") + @GET + public String mtls() { + return identity.getPrincipal().getName(); + } + + @Basic + @Path("basic") + @GET + public String basic() { + return identity.getPrincipal().getName(); + } + + } +} diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/TestTrustedIdentityProvider.java b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/TestTrustedIdentityProvider.java new file mode 100644 index 00000000000000..eb27a153283c98 --- /dev/null +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/TestTrustedIdentityProvider.java @@ -0,0 +1,40 @@ +package io.quarkus.resteasy.reactive.server.test.security; + +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +import jakarta.inject.Singleton; + +import io.quarkus.security.AuthenticationFailedException; +import io.quarkus.security.identity.AuthenticationRequestContext; +import io.quarkus.security.identity.IdentityProvider; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.request.TrustedAuthenticationRequest; +import io.quarkus.security.runtime.QuarkusPrincipal; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.quarkus.security.test.utils.TestIdentityController; +import io.quarkus.vertx.http.runtime.security.HttpSecurityUtils; +import io.smallrye.mutiny.Uni; + +@Singleton +public class TestTrustedIdentityProvider implements IdentityProvider { + @Override + public Class getRequestType() { + return TrustedAuthenticationRequest.class; + } + + @Override + public Uni authenticate(TrustedAuthenticationRequest request, + AuthenticationRequestContext context) { + if (HttpSecurityUtils.getRoutingContextAttribute(request) == null) { + return Uni.createFrom().failure(new AuthenticationFailedException()); + } + TestIdentityController.TestIdentity ident = TestIdentityController.identities.get(request.getPrincipal()); + if (ident == null) { + return Uni.createFrom().optional(Optional.empty()); + } + return Uni.createFrom().completionStage(CompletableFuture + .completedFuture(QuarkusSecurityIdentity.builder().setPrincipal(new QuarkusPrincipal(request.getPrincipal())) + .addRoles(ident.roles).build())); + } +} diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/resources/mtls/client-keystore.jks b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/resources/mtls/client-keystore.jks new file mode 100644 index 00000000000000..cf6d6ba454864d Binary files /dev/null and b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/resources/mtls/client-keystore.jks differ diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/resources/mtls/client-truststore.jks b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/resources/mtls/client-truststore.jks new file mode 100644 index 00000000000000..bf6371859c55fe Binary files /dev/null and b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/resources/mtls/client-truststore.jks differ diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/resources/mtls/mtls-basic-jks.conf b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/resources/mtls/mtls-basic-jks.conf new file mode 100644 index 00000000000000..354bc7601ad37c --- /dev/null +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/resources/mtls/mtls-basic-jks.conf @@ -0,0 +1,7 @@ +quarkus.http.ssl.certificate.key-store-file=server-keystore.jks +quarkus.http.ssl.certificate.key-store-password=secret +quarkus.http.ssl.certificate.trust-store-file=server-truststore.jks +quarkus.http.ssl.certificate.trust-store-password=password +quarkus.http.ssl.client-auth=REQUEST +quarkus.http.auth.basic=true +quarkus.http.auth.proactive=false \ No newline at end of file diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/resources/mtls/server-keystore.jks b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/resources/mtls/server-keystore.jks new file mode 100644 index 00000000000000..da33e8e7a16683 Binary files /dev/null and b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/resources/mtls/server-keystore.jks differ diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/resources/mtls/server-truststore.jks b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/resources/mtls/server-truststore.jks new file mode 100644 index 00000000000000..8ec8e126507b61 Binary files /dev/null and b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/resources/mtls/server-truststore.jks differ diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/security/EagerSecurityHandler.java b/extensions/resteasy-reactive/quarkus-resteasy-reactive/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/security/EagerSecurityHandler.java index f0dc401fa867bd..678aa395ac9f17 100644 --- a/extensions/resteasy-reactive/quarkus-resteasy-reactive/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/security/EagerSecurityHandler.java +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/security/EagerSecurityHandler.java @@ -131,7 +131,7 @@ public void onFailure(Throwable failure) { } static MethodDescription lazyMethodToMethodDescription(ResteasyReactiveResourceInfo lazyMethod) { - return new MethodDescription(lazyMethod.getResourceClass().getName(), + return new MethodDescription(lazyMethod.getActualDeclaringClassName(), lazyMethod.getName(), MethodDescription.typesAsStrings(lazyMethod.getParameterTypes())); } diff --git a/extensions/security-webauthn/deployment/pom.xml b/extensions/security-webauthn/deployment/pom.xml index 2fd2f5d1e28840..5316740ab27c8b 100644 --- a/extensions/security-webauthn/deployment/pom.xml +++ b/extensions/security-webauthn/deployment/pom.xml @@ -48,6 +48,10 @@ quarkus-test-security-webauthn test + + io.quarkus + quarkus-security-test-utils + io.rest-assured rest-assured diff --git a/extensions/security-webauthn/deployment/src/main/java/io/quarkus/security/webauthn/deployment/QuarkusSecurityWebAuthnProcessor.java b/extensions/security-webauthn/deployment/src/main/java/io/quarkus/security/webauthn/deployment/QuarkusSecurityWebAuthnProcessor.java index 64a531b6123572..cc96732be55fba 100644 --- a/extensions/security-webauthn/deployment/src/main/java/io/quarkus/security/webauthn/deployment/QuarkusSecurityWebAuthnProcessor.java +++ b/extensions/security-webauthn/deployment/src/main/java/io/quarkus/security/webauthn/deployment/QuarkusSecurityWebAuthnProcessor.java @@ -1,9 +1,12 @@ package io.quarkus.security.webauthn.deployment; +import java.util.List; import java.util.function.BooleanSupplier; import jakarta.inject.Singleton; +import org.jboss.jandex.DotName; + import io.quarkus.arc.deployment.AdditionalBeanBuildItem; import io.quarkus.arc.deployment.BeanContainerBuildItem; import io.quarkus.arc.deployment.SyntheticBeanBuildItem; @@ -13,6 +16,7 @@ import io.quarkus.deployment.annotations.ExecutionTime; import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.nativeimage.ServiceProviderBuildItem; +import io.quarkus.security.webauthn.WebAuthn; import io.quarkus.security.webauthn.WebAuthnAuthenticationMechanism; import io.quarkus.security.webauthn.WebAuthnAuthenticatorStorage; import io.quarkus.security.webauthn.WebAuthnBuildTimeConfig; @@ -20,6 +24,7 @@ import io.quarkus.security.webauthn.WebAuthnRecorder; import io.quarkus.security.webauthn.WebAuthnSecurity; import io.quarkus.security.webauthn.WebAuthnTrustedIdentityProvider; +import io.quarkus.vertx.http.deployment.HttpAuthMechanismAnnotationBuildItem; import io.quarkus.vertx.http.deployment.NonApplicationRootPathBuildItem; import io.quarkus.vertx.http.deployment.VertxWebRouterBuildItem; import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism; @@ -66,6 +71,12 @@ SyntheticBeanBuildItem initWebAuthnAuth( .supplier(recorder.setupWebAuthnAuthenticationMechanism()).done(); } + @BuildStep + List registerHttpAuthMechanismAnnotation() { + return List.of( + new HttpAuthMechanismAnnotationBuildItem(DotName.createSimple(WebAuthn.class), "webauthn")); + } + public static class IsEnabled implements BooleanSupplier { WebAuthnBuildTimeConfig config; diff --git a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/MultipleAuthMechResource.java b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/MultipleAuthMechResource.java new file mode 100644 index 00000000000000..bdd71aaec9e22b --- /dev/null +++ b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/MultipleAuthMechResource.java @@ -0,0 +1,27 @@ +package io.quarkus.security.webauthn.test; + +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; + +import io.quarkus.security.webauthn.WebAuthn; +import io.quarkus.vertx.http.runtime.security.Basic; +import io.smallrye.mutiny.Uni; + +@Path("/multiple-auth-mech") +public class MultipleAuthMechResource { + + @Basic + @Path("basic") + @POST + public Uni enforceBasicAuthMechanism() { + return Uni.createFrom().item("basic"); + } + + @WebAuthn + @Path("webauth") + @POST + public Uni enforceWebAuthMechanism() { + return Uni.createFrom().item("webauth"); + } + +} diff --git a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnAndBasicAuthnTest.java b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnAndBasicAuthnTest.java new file mode 100644 index 00000000000000..4e88303f920952 --- /dev/null +++ b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnAndBasicAuthnTest.java @@ -0,0 +1,103 @@ +package io.quarkus.security.webauthn.test; + +import java.util.List; + +import jakarta.inject.Inject; + +import org.hamcrest.Matchers; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.security.test.utils.TestIdentityController; +import io.quarkus.security.test.utils.TestIdentityProvider; +import io.quarkus.security.webauthn.WebAuthnController; +import io.quarkus.security.webauthn.WebAuthnUserProvider; +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.security.webauthn.WebAuthnEndpointHelper; +import io.quarkus.test.security.webauthn.WebAuthnHardware; +import io.quarkus.test.security.webauthn.WebAuthnTestUserProvider; +import io.restassured.RestAssured; +import io.restassured.filter.cookie.CookieFilter; +import io.restassured.specification.RequestSpecification; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.auth.webauthn.Authenticator; + +public class WebAuthnAndBasicAuthnTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(WebAuthnManualTestUserProvider.class, WebAuthnTestUserProvider.class, WebAuthnHardware.class, + TestResource.class, ManualResource.class, TestUtil.class, TestIdentityProvider.class, + MultipleAuthMechResource.class, TestIdentityController.class) + .addAsResource(new StringAsset("quarkus.http.auth.basic=true\n" + + "quarkus.http.auth.proactive=false\n"), "application.properties")); + + @Inject + WebAuthnUserProvider userProvider; + + @BeforeAll + public static void setupUsers() { + TestIdentityController.resetRoles() + .add("basic", "basic", "basic"); + RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); + } + + @Test + public void test() throws Exception { + + Assertions.assertTrue(userProvider.findWebAuthnCredentialsByUserName("stev").await().indefinitely().isEmpty()); + CookieFilter cookieFilter = new CookieFilter(); + String challenge = WebAuthnEndpointHelper.invokeRegistration("stev", cookieFilter); + WebAuthnHardware hardwareKey = new WebAuthnHardware(); + JsonObject registration = hardwareKey.makeRegistrationJson(challenge); + + // now finalise + RequestSpecification request = RestAssured + .given() + .filter(cookieFilter); + WebAuthnEndpointHelper.addWebAuthnRegistrationFormParameters(request, registration); + request + .post("/register") + .then().statusCode(200) + .body(Matchers.is("OK")) + .cookie(WebAuthnController.CHALLENGE_COOKIE, Matchers.is("")) + .cookie(WebAuthnController.USERNAME_COOKIE, Matchers.is("")) + .cookie("quarkus-credential", Matchers.notNullValue()); + + // make sure we stored the user + List users = userProvider.findWebAuthnCredentialsByUserName("stev").await().indefinitely(); + Assertions.assertEquals(1, users.size()); + Assertions.assertTrue(users.get(0).getUserName().equals("stev")); + Assertions.assertEquals(1, users.get(0).getCounter()); + + // make sure our login cookie works + checkLoggedIn(cookieFilter); + + // check that when an endpoint is annotated with @Basic, web auth won't work + RestAssured.given().filter(cookieFilter).post("/multiple-auth-mech/basic").then().statusCode(401); + // check that when an endpoint is annotated with @Basic, basic auth works + RestAssured.given().auth().preemptive().basic("basic", "basic").post("/multiple-auth-mech/basic").then().statusCode(200) + .body(Matchers.is("basic")); + + // check that when an endpoint is annotated with @WebAuthn, webuauth works + RestAssured.given().filter(cookieFilter).post("/multiple-auth-mech/webauth").then().statusCode(200) + .body(Matchers.is("webauth")); + // check that when an endpoint is annotated with @WebAuthn, basic auth won't work + RestAssured.given().auth().preemptive().basic("basic", "basic").post("/multiple-auth-mech/webauth").then() + .statusCode(302); + } + + private void checkLoggedIn(CookieFilter cookieFilter) { + RestAssured + .given() + .filter(cookieFilter) + .get("/secure") + .then() + .statusCode(200) + .body(Matchers.is("stev: [admin]")); + } +} diff --git a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthn.java b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthn.java new file mode 100644 index 00000000000000..422f4a6ce1075d --- /dev/null +++ b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthn.java @@ -0,0 +1,20 @@ +package io.quarkus.security.webauthn; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.quarkus.vertx.http.runtime.security.HttpAuthMechanism; + +/** + * Selects {@link WebAuthnAuthenticationMechanism}. + * Equivalent to '@HttpAuthMechanism("webauthn")'. + * + * @see HttpAuthMechanism for more information + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE, ElementType.METHOD }) +public @interface WebAuthn { + +} diff --git a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnAuthenticationMechanism.java b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnAuthenticationMechanism.java index 7cfffbe779caaa..d215c036303cc4 100644 --- a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnAuthenticationMechanism.java +++ b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnAuthenticationMechanism.java @@ -72,7 +72,7 @@ public Set> getCredentialTypes() { @Override public Uni getCredentialTransport(RoutingContext context) { - return Uni.createFrom().nullItem(); + return Uni.createFrom().item(new HttpCredentialTransport(HttpCredentialTransport.Type.COOKIE, "webauthn")); } public PersistentLoginManager getLoginManager() { diff --git a/extensions/vertx-http/deployment/pom.xml b/extensions/vertx-http/deployment/pom.xml index ed3dd67a09031b..ddecbaa516ca2d 100644 --- a/extensions/vertx-http/deployment/pom.xml +++ b/extensions/vertx-http/deployment/pom.xml @@ -33,6 +33,10 @@ io.quarkus quarkus-kubernetes-spi + + io.quarkus + quarkus-security-spi + diff --git a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/EagerSecurityInterceptorBindingBuildItem.java b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/EagerSecurityInterceptorBindingBuildItem.java new file mode 100644 index 00000000000000..2de1aa845af9ef --- /dev/null +++ b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/EagerSecurityInterceptorBindingBuildItem.java @@ -0,0 +1,41 @@ +package io.quarkus.vertx.http.deployment; + +import java.util.function.Consumer; +import java.util.function.Function; + +import org.jboss.jandex.DotName; + +import io.quarkus.builder.item.MultiBuildItem; +import io.vertx.ext.web.RoutingContext; + +/** + * Provides a way for extensions to register eager security interceptor. + * For example, the Vert.x HTTP extension registers {@link io.quarkus.vertx.http.runtime.security.HttpAuthMechanism} + * and an interceptor that sets annotation value ('@HttpAuthMechanism("basic") => 'basic') as routing context attribute. + * With disabled proactive authentication, these interceptors are guaranteed to run before any other security code + * of supported extensions (currently RESTEasy Classic and RESTEasy Reactive). + */ +public final class EagerSecurityInterceptorBindingBuildItem extends MultiBuildItem { + + private final DotName annotationBinding; + private final Function> interceptorCreator; + + /** + * + * @param interceptorBinding annotation name, 'value' attribute of annotation instances will be passed to the creator + * @param interceptorCreator accepts 'value' attribute of {@code interceptorBinding} instances and creates interceptor + */ + public EagerSecurityInterceptorBindingBuildItem(DotName interceptorBinding, + Function> interceptorCreator) { + this.annotationBinding = interceptorBinding; + this.interceptorCreator = interceptorCreator; + } + + public DotName getAnnotationBinding() { + return annotationBinding; + } + + Function> getInterceptorCreator() { + return interceptorCreator; + } +} diff --git a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/EagerSecurityInterceptorBuildItem.java b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/EagerSecurityInterceptorBuildItem.java deleted file mode 100644 index 0860336b4c18b2..00000000000000 --- a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/EagerSecurityInterceptorBuildItem.java +++ /dev/null @@ -1,38 +0,0 @@ -package io.quarkus.vertx.http.deployment; - -import java.util.List; -import java.util.Map; -import java.util.function.Consumer; - -import org.jboss.jandex.MethodInfo; - -import io.quarkus.builder.item.SimpleBuildItem; -import io.quarkus.runtime.RuntimeValue; -import io.quarkus.security.spi.runtime.MethodDescription; -import io.vertx.ext.web.RoutingContext; - -/** - * Bears collected security interceptors per method candidate. Methods are candidates because not each of them - * must be finally resolved to endpoint and invoked. - *

- * This build item should be consumed by every extension that run {@link io.quarkus.security.spi.runtime.SecurityCheck}s - * before CDI interceptors when proactive auth is disabled. - * - * @see EagerSecurityInterceptorCandidateBuildItem for detailed information on security filters - */ -public final class EagerSecurityInterceptorBuildItem extends SimpleBuildItem { - - private final List methodCandidates; - final Map, Consumer> methodCandidateToSecurityInterceptor; - - EagerSecurityInterceptorBuildItem( - List methodCandidates, - Map, Consumer> methodCandidateToSecurityInterceptor) { - this.methodCandidates = methodCandidates; - this.methodCandidateToSecurityInterceptor = Map.copyOf(methodCandidateToSecurityInterceptor); - } - - public boolean applyInterceptorOn(MethodInfo method) { - return methodCandidates.contains(method); - } -} diff --git a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/EagerSecurityInterceptorCandidateBuildItem.java b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/EagerSecurityInterceptorCandidateBuildItem.java deleted file mode 100644 index bbef55e404bb73..00000000000000 --- a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/EagerSecurityInterceptorCandidateBuildItem.java +++ /dev/null @@ -1,66 +0,0 @@ -package io.quarkus.vertx.http.deployment; - -import java.lang.reflect.Modifier; -import java.util.Objects; -import java.util.function.Consumer; - -import org.jboss.jandex.MethodInfo; - -import io.quarkus.builder.item.MultiBuildItem; -import io.quarkus.runtime.RuntimeValue; -import io.quarkus.security.spi.runtime.MethodDescription; -import io.vertx.ext.web.RoutingContext; - -/** - * Vert.X route handlers run before REST layer can't determine which endpoint is going to be invoked, - * what are endpoint annotations etc. Therefore, security setting that requires knowledge of invoked method - * (initial intention is to provide this with RESTEasy Reactive resources, however the principle is applicable to - * other stacks as well) and needs to be run prior to any security check should use this build item. The build - * item is only required for stacks that do not run security checks via CDI interceptors, as there, you can simply - * use interceptor with higher priority. - */ -public final class EagerSecurityInterceptorCandidateBuildItem extends MultiBuildItem { - - private final MethodInfo methodInfo; - private final RuntimeValue descriptionRuntimeValue; - private final Consumer securityInterceptor; - - /** - * @param methodInfo endpoint candidate; extensions exposing endpoints has final say on what is endpoint - * @param descriptionRuntimeValue endpoint candidate transformed into description - * @param securityInterceptor piece of code that should be run before {@link io.quarkus.security.spi.runtime.SecurityCheck} - * for annotated method is invoked; must be recorded during static init - */ - public EagerSecurityInterceptorCandidateBuildItem(MethodInfo methodInfo, - RuntimeValue descriptionRuntimeValue, - Consumer securityInterceptor) { - this.methodInfo = Objects.requireNonNull(methodInfo); - this.descriptionRuntimeValue = Objects.requireNonNull(descriptionRuntimeValue); - this.securityInterceptor = securityInterceptor; - } - - public static boolean hasProperEndpointModifiers(MethodInfo info) { - // synthetic methods are not endpoints - if ((info.flags() & 0x1000) != 0) { - return false; - } - // public only - if ((info.flags() & Modifier.PUBLIC) == 0) { - return false; - } - // instance methods only - return (info.flags() & Modifier.STATIC) == 0; - } - - MethodInfo getMethodInfo() { - return methodInfo; - } - - RuntimeValue getDescriptionRuntimeValue() { - return descriptionRuntimeValue; - } - - Consumer getSecurityInterceptor() { - return securityInterceptor; - } -} diff --git a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/EagerSecurityInterceptorMethodsBuildItem.java b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/EagerSecurityInterceptorMethodsBuildItem.java new file mode 100644 index 00000000000000..29fd416d56672e --- /dev/null +++ b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/EagerSecurityInterceptorMethodsBuildItem.java @@ -0,0 +1,46 @@ +package io.quarkus.vertx.http.deployment; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.jboss.jandex.DotName; +import org.jboss.jandex.MethodInfo; + +import io.quarkus.builder.item.MultiBuildItem; + +/** + * Bears collected intercepted methods annotated with registered security annotation. + * Security interceptor needs to be created and applied for each intercepted method. + *

+ * This build item should be consumed by every extension that run {@link io.quarkus.security.spi.runtime.SecurityCheck}s + * before CDI interceptors when proactive auth is disabled. Extensions need to provide intercepted methods for each + * annotation binding. For example all Jakarta REST resources annotated with the + * {@link io.quarkus.vertx.http.runtime.security.HttpAuthMechanism} annotation. + * + * @see EagerSecurityInterceptorBindingBuildItem for more information on security filters + */ +public final class EagerSecurityInterceptorMethodsBuildItem extends MultiBuildItem { + + /** + * Annotation binding value: '@HttpAuthMechanism("custom")' => 'custom'; mapped to annotated methods + */ + final Map> bindingValueToInterceptedMethods; + + /** + * Interceptor binding annotation name, like {@link io.quarkus.vertx.http.runtime.security.HttpAuthMechanism}. + */ + final DotName interceptorBinding; + + public EagerSecurityInterceptorMethodsBuildItem(Map> bindingValueToInterceptedMethods, + DotName interceptorBinding) { + this.bindingValueToInterceptedMethods = Map.copyOf(bindingValueToInterceptedMethods); + this.interceptorBinding = interceptorBinding; + } + + public List interceptedMethods() { + return bindingValueToInterceptedMethods.values().stream().flatMap(Collection::stream).collect(Collectors.toList()); + } + +} diff --git a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/HttpAuthMechanismAnnotationBuildItem.java b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/HttpAuthMechanismAnnotationBuildItem.java new file mode 100644 index 00000000000000..ee0c5632255eda --- /dev/null +++ b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/HttpAuthMechanismAnnotationBuildItem.java @@ -0,0 +1,29 @@ +package io.quarkus.vertx.http.deployment; + +import java.util.Objects; + +import org.jboss.jandex.DotName; + +import io.quarkus.builder.item.MultiBuildItem; +import io.quarkus.vertx.http.runtime.security.HttpAuthMechanism; + +/** + * Register {@link io.quarkus.vertx.http.runtime.security.HttpAuthMechanism} meta annotations. + * This way, users can use {@link io.quarkus.vertx.http.runtime.security.Basic} instead of '@HttpAuthMechanism("basic")'. + */ +public final class HttpAuthMechanismAnnotationBuildItem extends MultiBuildItem { + + /** + * Annotation name, for example {@link io.quarkus.vertx.http.runtime.security.Basic}. + */ + final DotName annotationName; + /** + * Authentication mechanism scheme, as defined by {@link HttpAuthMechanism#value()}. + */ + final String authMechanismScheme; + + public HttpAuthMechanismAnnotationBuildItem(DotName annotationName, String authMechanismScheme) { + this.annotationName = Objects.requireNonNull(annotationName); + this.authMechanismScheme = Objects.requireNonNull(authMechanismScheme); + } +} diff --git a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/HttpSecurityProcessor.java b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/HttpSecurityProcessor.java index 84b2aaca206dcd..6802fa5951eb5c 100644 --- a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/HttpSecurityProcessor.java +++ b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/HttpSecurityProcessor.java @@ -1,12 +1,17 @@ package io.quarkus.vertx.http.deployment; import static io.quarkus.arc.processor.DotNames.APPLICATION_SCOPED; +import static java.util.stream.Collectors.toMap; +import java.lang.reflect.Modifier; import java.security.Permission; +import java.util.Collection; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.function.BiFunction; import java.util.function.Consumer; import java.util.function.Function; @@ -16,14 +21,20 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Singleton; +import org.jboss.jandex.AnnotationInstance; +import org.jboss.jandex.AnnotationTarget; +import org.jboss.jandex.AnnotationValue; import org.jboss.jandex.ClassInfo; +import org.jboss.jandex.DotName; import org.jboss.jandex.IndexView; import org.jboss.jandex.MethodInfo; import org.jboss.jandex.Type; import io.quarkus.arc.deployment.AdditionalBeanBuildItem; +import io.quarkus.arc.deployment.AnnotationsTransformerBuildItem; import io.quarkus.arc.deployment.BeanContainerListenerBuildItem; import io.quarkus.arc.deployment.SyntheticBeanBuildItem; +import io.quarkus.arc.processor.AnnotationsTransformer; import io.quarkus.deployment.Capabilities; import io.quarkus.deployment.Capability; import io.quarkus.deployment.annotations.BuildProducer; @@ -35,20 +46,26 @@ import io.quarkus.runtime.RuntimeValue; import io.quarkus.runtime.configuration.ConfigurationException; import io.quarkus.security.StringPermission; +import io.quarkus.security.spi.AdditionalSecuredMethodsBuildItem; +import io.quarkus.security.spi.SecurityTransformerUtils; import io.quarkus.security.spi.runtime.MethodDescription; import io.quarkus.vertx.http.runtime.HttpBuildTimeConfig; import io.quarkus.vertx.http.runtime.PolicyConfig; import io.quarkus.vertx.http.runtime.management.ManagementInterfaceBuildTimeConfig; import io.quarkus.vertx.http.runtime.security.AuthenticatedHttpSecurityPolicy; +import io.quarkus.vertx.http.runtime.security.Basic; import io.quarkus.vertx.http.runtime.security.BasicAuthenticationMechanism; import io.quarkus.vertx.http.runtime.security.DenySecurityPolicy; import io.quarkus.vertx.http.runtime.security.EagerSecurityInterceptorStorage; +import io.quarkus.vertx.http.runtime.security.Form; import io.quarkus.vertx.http.runtime.security.FormAuthenticationMechanism; +import io.quarkus.vertx.http.runtime.security.HttpAuthMechanism; import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism; import io.quarkus.vertx.http.runtime.security.HttpAuthenticator; import io.quarkus.vertx.http.runtime.security.HttpAuthorizer; import io.quarkus.vertx.http.runtime.security.HttpSecurityPolicy; import io.quarkus.vertx.http.runtime.security.HttpSecurityRecorder; +import io.quarkus.vertx.http.runtime.security.MTLS; import io.quarkus.vertx.http.runtime.security.MtlsAuthenticationMechanism; import io.quarkus.vertx.http.runtime.security.PathMatchingHttpSecurityPolicy; import io.quarkus.vertx.http.runtime.security.PermitSecurityPolicy; @@ -60,6 +77,8 @@ public class HttpSecurityProcessor { + private static final DotName AUTH_MECHANISM_NAME = DotName.createSimple(HttpAuthMechanism.class); + @BuildStep @Record(ExecutionTime.STATIC_INIT) public void builtins(BuildProducer producer, @@ -289,20 +308,84 @@ void setupAuthenticationMechanisms( } @BuildStep - void collectEagerSecurityInterceptors(List interceptorCandidates, - HttpBuildTimeConfig buildTimeConfig, Capabilities capabilities, - BuildProducer interceptorsProducer) { - if (!buildTimeConfig.auth.proactive && capabilities.isPresent(Capability.SECURITY) - && !interceptorCandidates.isEmpty()) { - List allInterceptedMethodInfos = interceptorCandidates - .stream() - .map(EagerSecurityInterceptorCandidateBuildItem::getMethodInfo) - .collect(Collectors.toList()); - Map, Consumer> methodToInterceptor = interceptorCandidates - .stream() - .collect(Collectors.toMap(EagerSecurityInterceptorCandidateBuildItem::getDescriptionRuntimeValue, - EagerSecurityInterceptorCandidateBuildItem::getSecurityInterceptor)); - interceptorsProducer.produce(new EagerSecurityInterceptorBuildItem(allInterceptedMethodInfos, methodToInterceptor)); + List registerHttpAuthMechanismAnnotations() { + return List.of( + new HttpAuthMechanismAnnotationBuildItem(DotName.createSimple(Basic.class), "basic"), + new HttpAuthMechanismAnnotationBuildItem(DotName.createSimple(Form.class), "form"), + new HttpAuthMechanismAnnotationBuildItem(DotName.createSimple(MTLS.class), "X509")); + } + + @BuildStep + @Record(ExecutionTime.STATIC_INIT) + void registerAuthMechanismSelectionInterceptor(Capabilities capabilities, HttpBuildTimeConfig buildTimeConfig, + BuildProducer bindingProducer, HttpSecurityRecorder recorder, + BuildProducer annotationsTransformerProducer, + BuildProducer additionalSecuredMethodsProducer, + List additionalHttpAuthMechAnnotations, + CombinedIndexBuildItem combinedIndexBuildItem) { + boolean registerInterceptorBinding = false; + + // methods annotated with @HttpAuthMechanism that we should additionally secure; + // when there is no other RBAC annotation applied + // then by default @HttpAuthMechanism("any-value") == @Authenticated + Set methodsWithoutRbacAnnotations = new HashSet<>(); + + // other extensions can register their HttpAuthMechanism meta annotations, like @Form and @Basic + // it's a same thing, so @HttpAuthMechanism("basic") == @Basic + for (HttpAuthMechanismAnnotationBuildItem annotationBuildItem : additionalHttpAuthMechAnnotations) { + var instances = combinedIndexBuildItem.getIndex().getAnnotations(annotationBuildItem.annotationName); + if (!instances.isEmpty()) { + if (!registerInterceptorBinding) { + registerInterceptorBinding = true; + validateAuthMechanismAnnotationUsage(capabilities, buildTimeConfig, annotationBuildItem.annotationName); + } + + final Set annotatedMethods = collectAnnotatedMethods(instances); + final Set annotatedClasses = collectAnnotatedClasses(instances); + + // e.g. collect @Basic without @RolesAllowed, @PermissionsAllowed, .. + methodsWithoutRbacAnnotations.addAll(collectMethodsWithoutRbacAnnotation(annotatedMethods)); + methodsWithoutRbacAnnotations.addAll(collectClassMethodsWithoutRbacAnnotation(annotatedClasses)); + + // @Basic => @HttpAuthMechanism("basic") + final Set annotatedMethodsDesc = annotatedMethods + .stream() + .map(HttpSecurityProcessor::createMethodDescription) + .collect(Collectors.toSet()); + annotationsTransformerProducer.produce( + new AnnotationsTransformerBuildItem(new HttpAuthMechanismAnnotationsTransformer(annotatedMethodsDesc, + annotatedClasses, annotationBuildItem.authMechanismScheme))); + } + } + + // @HttpAuthMechanism used directly on methods / classes + var httpAuthMechanismAnnotations = combinedIndexBuildItem.getIndex().getAnnotations(AUTH_MECHANISM_NAME); + if (!httpAuthMechanismAnnotations.isEmpty()) { + if (!registerInterceptorBinding) { + registerInterceptorBinding = true; + validateAuthMechanismAnnotationUsage(capabilities, buildTimeConfig, AUTH_MECHANISM_NAME); + } + + final Set annotatedMethods = collectAnnotatedMethods(httpAuthMechanismAnnotations); + final Set annotatedClasses = collectAnnotatedClasses(httpAuthMechanismAnnotations); + + // e.g. collect @HttpAuthMechanism without @RolesAllowed, @PermissionsAllowed, .. + methodsWithoutRbacAnnotations.addAll(collectMethodsWithoutRbacAnnotation(annotatedMethods)); + methodsWithoutRbacAnnotations.addAll(collectClassMethodsWithoutRbacAnnotation(annotatedClasses)); + } + + if (registerInterceptorBinding) { + // register method interceptor that will be run before security checks + bindingProducer.produce(new EagerSecurityInterceptorBindingBuildItem(AUTH_MECHANISM_NAME, + recorder.authMechanismSelectionInterceptorCreator())); + recorder.selectAuthMechanismViaAnnotation(); + + // make all @HttpAuthMechanism annotation targets authenticated by default + if (!methodsWithoutRbacAnnotations.isEmpty()) { + // @RolesAllowed("**") == @Authenticated + additionalSecuredMethodsProducer.produce( + new AdditionalSecuredMethodsBuildItem(methodsWithoutRbacAnnotations, Optional.of(List.of("**")))); + } } } @@ -310,19 +393,161 @@ void collectEagerSecurityInterceptors(List producer, - Optional interceptors) { - if (interceptors.isPresent()) { + List interceptorBindings, + List interceptorMethods) { + if (!interceptorMethods.isEmpty()) { + + final var bindingNameToInterceptorCreator = interceptorBindings + .stream() + .collect(toMap(EagerSecurityInterceptorBindingBuildItem::getAnnotationBinding, + EagerSecurityInterceptorBindingBuildItem::getInterceptorCreator)); + + final var methodCache = new HashMap>(); + final var methodDescriptionToInterceptor = new HashMap, Consumer>(); + for (EagerSecurityInterceptorMethodsBuildItem interceptorMethod : interceptorMethods) { + var interceptorCreator = bindingNameToInterceptorCreator.get(interceptorMethod.interceptorBinding); + for (Map.Entry> e : interceptorMethod.bindingValueToInterceptedMethods.entrySet()) { + var annotationValue = e.getKey(); + var annotatedMethods = e.getValue(); + var interceptor = recorder.createEagerSecurityInterceptor(interceptorCreator, annotationValue); + for (MethodInfo method : annotatedMethods) { + // transform method info to description + final RuntimeValue methodDescription = methodCache + .computeIfAbsent(method, mi -> { + String[] paramTypes = method.parameterTypes().stream().map(t -> t.name().toString()) + .toArray(String[]::new); + String className = method.declaringClass().name().toString(); + String methodName = method.name(); + return recorder.createMethodDescription(className, methodName, paramTypes); + }); + + // add (methodDesc -> interceptor) to the storage + methodDescriptionToInterceptor.compute(methodDescription, + (desc, existingInterceptor) -> existingInterceptor == null ? interceptor + : recorder.compoundSecurityInterceptor(interceptor, existingInterceptor)); + } + } + } + producer.produce(SyntheticBeanBuildItem .configure(EagerSecurityInterceptorStorage.class) .scope(ApplicationScoped.class) - .supplier( - recorder.createSecurityInterceptorStorage(interceptors.get().methodCandidateToSecurityInterceptor)) + .supplier(recorder.createSecurityInterceptorStorage(methodDescriptionToInterceptor)) .unremovable() .done()); } } + private static MethodDescription createMethodDescription(MethodInfo mi) { + String[] paramTypes = new String[mi.parametersCount()]; + for (int i = 0; i < mi.parametersCount(); i++) { + paramTypes[i] = mi.parameterTypes().get(i).name().toString(); + } + return new MethodDescription(mi.declaringClass().name().toString(), mi.name(), + paramTypes); + } + + private static void validateAuthMechanismAnnotationUsage(Capabilities capabilities, HttpBuildTimeConfig buildTimeConfig, + DotName annotationName) { + if (buildTimeConfig.auth.proactive + || (!capabilities.isPresent(Capability.RESTEASY_REACTIVE) && !capabilities.isPresent(Capability.RESTEASY))) { + throw new ConfigurationException("Annotation '" + annotationName + "' can only be used when" + + " proactive authentication is disabled and either RESTEasy Reactive or RESTEasy Classic" + + " extension is present"); + } + } + private static boolean isMtlsClientAuthenticationEnabled(HttpBuildTimeConfig buildTimeConfig) { return !ClientAuth.NONE.equals(buildTimeConfig.tlsClientAuth); } + + private static Set collectClassMethodsWithoutRbacAnnotation(Collection classes) { + return classes + .stream() + .filter(c -> !SecurityTransformerUtils.hasSecurityAnnotation(c)) + .map(ClassInfo::methods) + .flatMap(Collection::stream) + .filter(HttpSecurityProcessor::hasProperEndpointModifiers) + .filter(m -> !SecurityTransformerUtils.hasSecurityAnnotation(m)) + .collect(Collectors.toSet()); + } + + private static Set collectMethodsWithoutRbacAnnotation(Collection methods) { + return methods + .stream() + .filter(m -> !SecurityTransformerUtils.hasSecurityAnnotation(m)) + .collect(Collectors.toSet()); + } + + private static Set collectAnnotatedClasses(Collection instances) { + return instances + .stream() + .map(AnnotationInstance::target) + .filter(target -> target.kind() == AnnotationTarget.Kind.CLASS) + .map(AnnotationTarget::asClass) + .collect(Collectors.toSet()); + } + + private static Set collectAnnotatedMethods(Collection instances) { + return instances + .stream() + .map(AnnotationInstance::target) + .filter(target -> target.kind() == AnnotationTarget.Kind.METHOD) + .map(AnnotationTarget::asMethod) + .collect(Collectors.toSet()); + } + + private static boolean hasProperEndpointModifiers(MethodInfo info) { + // synthetic methods are not endpoints + if ((info.flags() & 0x1000) != 0) { + return false; + } + // public only + if ((info.flags() & Modifier.PUBLIC) == 0) { + return false; + } + // instance methods only + return (info.flags() & Modifier.STATIC) == 0; + } + + private static class HttpAuthMechanismAnnotationsTransformer implements AnnotationsTransformer { + + private final Set annotatedMethods; + private final Set annotatedClasses; + private final String authMechanismScheme; + + private HttpAuthMechanismAnnotationsTransformer(Set annotatedMethods, + Set annotatedClasses, String authMechanismScheme) { + this.annotatedMethods = Set.copyOf(annotatedMethods); + this.annotatedClasses = Set.copyOf(annotatedClasses); + this.authMechanismScheme = authMechanismScheme; + } + + @Override + public boolean appliesTo(AnnotationTarget.Kind kind) { + return kind == AnnotationTarget.Kind.METHOD || kind == AnnotationTarget.Kind.CLASS; + } + + @Override + public void transform(TransformationContext context) { + if (context.getTarget().kind() == AnnotationTarget.Kind.CLASS) { + ClassInfo classInfo = context.getTarget().asClass(); + if (annotatedClasses.contains(classInfo)) { + addHttpAuthMechanismAnnotation(context); + } + } else { + MethodDescription method = createMethodDescription(context.getTarget().asMethod()); + if (annotatedMethods.contains(method)) { + addHttpAuthMechanismAnnotation(context); + } + } + } + + private void addHttpAuthMechanismAnnotation(TransformationContext context) { + context.transform() + .add(AUTH_MECHANISM_NAME, AnnotationValue.createStringValue("value", authMechanismScheme)) + .done(); + } + } + } diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/Basic.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/Basic.java new file mode 100644 index 00000000000000..2812d04820ec95 --- /dev/null +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/Basic.java @@ -0,0 +1,18 @@ +package io.quarkus.vertx.http.runtime.security; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Selects {@link BasicAuthenticationMechanism}. + * Equivalent to '@HttpAuthMechanism("basic")'. + * + * @see HttpAuthMechanism for more information + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE, ElementType.METHOD }) +public @interface Basic { + +} diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/Form.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/Form.java new file mode 100644 index 00000000000000..15ea9adbc81092 --- /dev/null +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/Form.java @@ -0,0 +1,18 @@ +package io.quarkus.vertx.http.runtime.security; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Selects {@link FormAuthenticationMechanism}. + * Equivalent to '@HttpAuthMechanism("form")'. + * + * @see HttpAuthMechanism for more information + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE, ElementType.METHOD }) +public @interface Form { + +} diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpAuthMechanism.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpAuthMechanism.java new file mode 100644 index 00000000000000..a6770786c8b961 --- /dev/null +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpAuthMechanism.java @@ -0,0 +1,25 @@ +package io.quarkus.vertx.http.runtime.security; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.vertx.ext.web.RoutingContext; + +/** + * Provides a way to select {@link HttpAuthenticationMechanism} used for a REST endpoint authentication. + * This annotation can only be used when proactive authentication is disabled. Using the annotation with + * enabled proactive authentication will lead to build-time failure. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE, ElementType.METHOD }) +@Inherited +public @interface HttpAuthMechanism { + /** + * {@link HttpAuthenticationMechanism} scheme as returned by {@link HttpCredentialTransport#getAuthenticationScheme()}. + * Mechanisms can set this name inside {@link HttpAuthenticationMechanism#getCredentialTransport(RoutingContext)}. + */ + String value(); +} diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpAuthenticator.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpAuthenticator.java index 196abfd38e61e3..49d434033e835b 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpAuthenticator.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpAuthenticator.java @@ -1,5 +1,7 @@ package io.quarkus.vertx.http.runtime.security; +import static java.lang.Boolean.TRUE; + import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; @@ -14,6 +16,7 @@ import org.jboss.logging.Logger; import io.netty.handler.codec.http.HttpResponseStatus; +import io.quarkus.security.AuthenticationFailedException; import io.quarkus.security.identity.IdentityProvider; import io.quarkus.security.identity.IdentityProviderManager; import io.quarkus.security.identity.SecurityIdentity; @@ -29,11 +32,21 @@ public class HttpAuthenticator { private static final Logger log = Logger.getLogger(HttpAuthenticator.class); + /** + * Added to a {@link RoutingContext} as selected authentication mechanism. + */ + private static final String AUTH_MECHANISM = HttpAuthenticator.class.getName() + "#auth-mechanism"; + + /** + * Added to a {@link RoutingContext} when {@link this#attemptAuthentication(RoutingContext)} is invoked. + */ + private static final String ATTEMPT_AUTH_INVOKED = HttpAuthenticator.class.getName() + "#attemptAuthentication"; + private static volatile boolean useAnnotationBasedSelection = false; + private final IdentityProviderManager identityProviderManager; private final HttpAuthenticationMechanism[] mechanisms; public HttpAuthenticator(IdentityProviderManager identityProviderManager, - Instance pathMatchingPolicy, Instance httpAuthenticationMechanism, Instance> providers) { this.identityProviderManager = identityProviderManager; @@ -89,9 +102,11 @@ public Uni attemptAuthentication(RoutingContext routingContext AbstractPathMatchingHttpSecurityPolicy pathMatchingPolicy = routingContext .get(AbstractPathMatchingHttpSecurityPolicy.class.getName()); - String pathSpecificMechanism = pathMatchingPolicy != null - ? pathMatchingPolicy.getAuthMechanismName(routingContext) - : null; + if (useAnnotationBasedSelection) { + rememberAuthAttempted(routingContext); + } + + String pathSpecificMechanism = getPathSpecificMechanism(routingContext, pathMatchingPolicy); Uni matchingMechUni = findBestCandidateMechanism(routingContext, pathSpecificMechanism); if (matchingMechUni == null) { return createSecurityIdentity(routingContext); @@ -114,6 +129,14 @@ public Uni apply(HttpAuthenticationMechanism mech) { } + private String getPathSpecificMechanism(RoutingContext routingContext, + AbstractPathMatchingHttpSecurityPolicy pathMatchingPolicy) { + if (useAnnotationBasedSelection && isAuthMechanismSelectedViaAnnotation(routingContext)) { + return routingContext.get(AUTH_MECHANISM); + } + return pathMatchingPolicy != null ? pathMatchingPolicy.getAuthMechanismName(routingContext) : null; + } + private Uni createSecurityIdentity(RoutingContext routingContext) { Uni result = mechanisms[0].authenticate(routingContext, identityProviderManager); for (int i = 1; i < mechanisms.length; ++i) { @@ -277,14 +300,29 @@ public HttpCredentialTransport getCredentialTransport() { } - static class NoopCloseTask implements Runnable { + private static void rememberAuthAttempted(RoutingContext routingContext) { + routingContext.put(ATTEMPT_AUTH_INVOKED, TRUE); + } - static final NoopCloseTask INSTANCE = new NoopCloseTask(); + private static boolean isAuthMechanismSelectedViaAnnotation(RoutingContext routingContext) { + return routingContext.get(AUTH_MECHANISM) != null; + } - @Override - public void run() { + private static boolean requestAlreadyAuthenticated(RoutingContext routingContext) { + return routingContext.get(ATTEMPT_AUTH_INVOKED) == TRUE; + } + static void selectAuthMechanism(RoutingContext routingContext, String authMechanism) { + if (requestAlreadyAuthenticated(routingContext)) { + throw new AuthenticationFailedException("Request has been authenticated before the '" + authMechanism + + "' authentication mechanism was selected with an annotation. Most often, this will happen when " + + "you configure HTTPSecurityPolicy that requires authentication (like 'roles-allowed' policy). " + + "Please revise your 'quarkus.http.auth.permission.*' configuration properties"); } + routingContext.put(AUTH_MECHANISM, authMechanism); } + static void useAnnotationBasedAuthMechanismSelection() { + useAnnotationBasedSelection = true; + } } diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityRecorder.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityRecorder.java index 84b3ef16064ade..505fcb30644996 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityRecorder.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityRecorder.java @@ -1,6 +1,7 @@ package io.quarkus.vertx.http.runtime.security; import static io.quarkus.security.PermissionsAllowed.PERMISSION_TO_ACTION_SEPARATOR; +import static io.quarkus.vertx.http.runtime.security.HttpAuthenticator.selectAuthMechanism; import java.lang.reflect.InvocationTargetException; import java.security.Permission; @@ -299,6 +300,47 @@ public PermissionToActions apply(String s) { }).addAction(action); } + public RuntimeValue createMethodDescription(String className, String methodName, String[] paramTypes) { + return new RuntimeValue<>(new MethodDescription(className, methodName, paramTypes)); + } + + public Function> authMechanismSelectionInterceptorCreator() { + return new Function>() { + @Override + public Consumer apply(String authMechanismName) { + // when endpoint is annotated with @HttpAuthMechanism("my-mechanism"), we add this mechanism + // to the event so that when request is being authenticated, the HTTP authenticator will know + // what mechanism should be used + return new Consumer() { + @Override + public void accept(RoutingContext routingContext) { + selectAuthMechanism(routingContext, authMechanismName); + } + }; + } + }; + } + + public Consumer createEagerSecurityInterceptor( + Function> interceptorCreator, String annotationValue) { + return interceptorCreator.apply(annotationValue); + } + + public Consumer compoundSecurityInterceptor(Consumer interceptor1, + Consumer interceptor2) { + return new Consumer() { + @Override + public void accept(RoutingContext routingContext) { + interceptor1.accept(routingContext); + interceptor2.accept(routingContext); + } + }; + } + + public void selectAuthMechanismViaAnnotation() { + HttpAuthenticator.useAnnotationBasedAuthMechanismSelection(); + } + public static abstract class DefaultAuthFailureHandler implements BiConsumer { protected DefaultAuthFailureHandler() { diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/MTLS.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/MTLS.java new file mode 100644 index 00000000000000..12a7eab3cd3a69 --- /dev/null +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/MTLS.java @@ -0,0 +1,18 @@ +package io.quarkus.vertx.http.runtime.security; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Selects {@link MtlsAuthenticationMechanism}. + * Equivalent to '@HttpAuthMechanism("X509")'. + * + * @see HttpAuthMechanism for more information + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE, ElementType.METHOD }) +public @interface MTLS { + +} diff --git a/independent-projects/resteasy-reactive/common/processor/src/main/java/org/jboss/resteasy/reactive/common/processor/EndpointIndexer.java b/independent-projects/resteasy-reactive/common/processor/src/main/java/org/jboss/resteasy/reactive/common/processor/EndpointIndexer.java index 0f4dca20e286b9..e64a99144e3b68 100644 --- a/independent-projects/resteasy-reactive/common/processor/src/main/java/org/jboss/resteasy/reactive/common/processor/EndpointIndexer.java +++ b/independent-projects/resteasy-reactive/common/processor/src/main/java/org/jboss/resteasy/reactive/common/processor/EndpointIndexer.java @@ -513,6 +513,23 @@ private static List collectEndpoints(ClassInfo currentClassInfo, return ret; } + /** + * Return endpoints defined on {@code classInfo} or inherited by {@code classInfo}. + * + * @param classInfo resource class + * @return endpoint to declaring class + */ + public static Map collectEndpoints(ClassInfo classInfo, Map httpAnnotationToMethod, + IndexView index, ApplicationScanningResult applicationScanningResult, AnnotationStore annotationStore) { + Collection endpoints = collectEndpoints(classInfo, classInfo, new HashSet<>(), new HashSet<>(), true, + httpAnnotationToMethod, index, applicationScanningResult, annotationStore); + Map ret = new HashMap<>(); + for (FoundEndpoint endpoint : endpoints) { + ret.put(endpoint.methodInfo, endpoint.classInfo); + } + return ret; + } + protected String handleTrailingSlash(String path) { return path; } diff --git a/independent-projects/resteasy-reactive/server/processor/src/main/java/org/jboss/resteasy/reactive/server/processor/ServerEndpointIndexer.java b/independent-projects/resteasy-reactive/server/processor/src/main/java/org/jboss/resteasy/reactive/server/processor/ServerEndpointIndexer.java index 5044f7eb4b3ee2..07fb3fba149d46 100644 --- a/independent-projects/resteasy-reactive/server/processor/src/main/java/org/jboss/resteasy/reactive/server/processor/ServerEndpointIndexer.java +++ b/independent-projects/resteasy-reactive/server/processor/src/main/java/org/jboss/resteasy/reactive/server/processor/ServerEndpointIndexer.java @@ -183,6 +183,7 @@ protected ServerResourceMethod createResourceMethod(MethodInfo methodInfo, Class } } serverResourceMethod.setHandlerChainCustomizers(methodCustomizers); + serverResourceMethod.setActualDeclaringClassName(methodInfo.declaringClass().name().toString()); return serverResourceMethod; } diff --git a/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/startup/RuntimeResourceDeployment.java b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/startup/RuntimeResourceDeployment.java index b8697b15158c5f..c315714818c807 100644 --- a/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/startup/RuntimeResourceDeployment.java +++ b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/startup/RuntimeResourceDeployment.java @@ -189,7 +189,7 @@ public RuntimeResource buildResourceMethod(ResourceClass clazz, ResteasyReactiveResourceInfo lazyMethod = new ResteasyReactiveResourceInfo(method.getName(), resourceClass, parameterDeclaredUnresolvedTypes, classAnnotationNames, method.getMethodAnnotationNames(), - !defaultBlocking && !method.isBlocking()); + !defaultBlocking && !method.isBlocking(), method.getActualDeclaringClassName()); RuntimeInterceptorDeployment.MethodInterceptorContext interceptorDeployment = runtimeInterceptorDeployment .forMethod(method, lazyMethod); diff --git a/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/model/ServerResourceMethod.java b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/model/ServerResourceMethod.java index 9c8620e9da3418..250dd3818421a1 100644 --- a/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/model/ServerResourceMethod.java +++ b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/model/ServerResourceMethod.java @@ -18,6 +18,7 @@ public class ServerResourceMethod extends ResourceMethod { private List handlerChainCustomizers = new ArrayList<>(); private ParameterExtractor customerParameterExtractor; + private String actualDeclaringClassName; public ServerResourceMethod() { } @@ -70,4 +71,13 @@ public ServerResourceMethod setCustomerParameterExtractor(ParameterExtractor cus this.customerParameterExtractor = customerParameterExtractor; return this; } + + public ServerResourceMethod setActualDeclaringClassName(String actualDeclaringClassName) { + this.actualDeclaringClassName = actualDeclaringClassName; + return this; + } + + public String getActualDeclaringClassName() { + return actualDeclaringClassName; + } } diff --git a/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/spi/ResteasyReactiveResourceInfo.java b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/spi/ResteasyReactiveResourceInfo.java index 5e8b3377e4d77d..a9b258fb9847ca 100644 --- a/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/spi/ResteasyReactiveResourceInfo.java +++ b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/spi/ResteasyReactiveResourceInfo.java @@ -22,6 +22,10 @@ public class ResteasyReactiveResourceInfo implements ResourceInfo { private final Class[] parameterTypes; private final Set classAnnotationNames; private final Set methodAnnotationNames; + /** + * This class name will only differ from {@link this#declaringClass} name when the {@link this#method} was inherited. + */ + private final String actualDeclaringClassName; /** * If it's non-blocking method within the runtime that won't always default to blocking */ @@ -34,13 +38,15 @@ public class ResteasyReactiveResourceInfo implements ResourceInfo { private volatile String methodId; public ResteasyReactiveResourceInfo(String name, Class declaringClass, Class[] parameterTypes, - Set classAnnotationNames, Set methodAnnotationNames, boolean isNonBlocking) { + Set classAnnotationNames, Set methodAnnotationNames, boolean isNonBlocking, + String actualDeclaringClassName) { this.name = name; this.declaringClass = declaringClass; this.parameterTypes = parameterTypes; this.classAnnotationNames = classAnnotationNames; this.methodAnnotationNames = methodAnnotationNames; this.isNonBlocking = isNonBlocking; + this.actualDeclaringClassName = actualDeclaringClassName; } public String getName() { @@ -119,4 +125,8 @@ public String getMethodId() { } return methodId; } + + public String getActualDeclaringClassName() { + return actualDeclaringClassName; + } } diff --git a/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/CustomBasicHttpAuthMechanism.java b/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/CustomBasicHttpAuthMechanism.java new file mode 100644 index 00000000000000..691628206ad613 --- /dev/null +++ b/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/CustomBasicHttpAuthMechanism.java @@ -0,0 +1,28 @@ +package io.quarkus.it.keycloak; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.vertx.http.runtime.security.BasicAuthenticationMechanism; +import io.quarkus.vertx.http.runtime.security.ChallengeData; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; + +/** + * Just like {@link BasicAuthenticationMechanism}, but only challenge on demand, for when + * {@link io.quarkus.oidc.runtime.CodeAuthenticationMechanism} is not selected explicitly, it can happen + * that challenge is send to {@link BasicAuthenticationMechanism}. + */ +@ApplicationScoped +public class CustomBasicHttpAuthMechanism extends BasicAuthenticationMechanism { + public CustomBasicHttpAuthMechanism() { + super(null, false); + } + + @Override + public Uni getChallenge(RoutingContext context) { + if (context.request().getHeader("custom") != null) { + return super.getChallenge(context); + } + return Uni.createFrom().nullItem(); + } +} diff --git a/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/CustomIdentityProvider.java b/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/CustomIdentityProvider.java new file mode 100644 index 00000000000000..1c6136e6d3ca2a --- /dev/null +++ b/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/CustomIdentityProvider.java @@ -0,0 +1,50 @@ +package io.quarkus.it.keycloak; + +import java.security.Permission; +import java.util.List; +import java.util.function.Function; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import io.quarkus.security.credential.PasswordCredential; +import io.quarkus.security.identity.AuthenticationRequestContext; +import io.quarkus.security.identity.IdentityProvider; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.request.UsernamePasswordAuthenticationRequest; +import io.quarkus.security.runtime.QuarkusPrincipal; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; + +@ApplicationScoped +public class CustomIdentityProvider implements IdentityProvider { + + @Inject + RoutingContext routingContext; + + @Override + public Class getRequestType() { + return UsernamePasswordAuthenticationRequest.class; + } + + @Override + public Uni authenticate(UsernamePasswordAuthenticationRequest request, + AuthenticationRequestContext context) { + if (routingContext.request().getHeader("custom") == null) { + return Uni.createFrom().nullItem(); + } + QuarkusSecurityIdentity identity = QuarkusSecurityIdentity.builder() + .setPrincipal(new QuarkusPrincipal("admin")) + .addCredential(new PasswordCredential("admin".toCharArray())) + .addPermissionCheckers(List.of(new Function>() { + @Override + public Uni apply(Permission permission) { + return Uni.createFrom().item("permission1".equals(permission.getName())); + } + })) + .build(); + return Uni.createFrom().item(identity); + } + +} diff --git a/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/MultipleAuthMechanismResource.java b/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/MultipleAuthMechanismResource.java new file mode 100644 index 00000000000000..59f96f423414f8 --- /dev/null +++ b/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/MultipleAuthMechanismResource.java @@ -0,0 +1,27 @@ +package io.quarkus.it.keycloak; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import io.quarkus.oidc.CodeFlow; +import io.quarkus.security.PermissionsAllowed; +import io.quarkus.vertx.http.runtime.security.Basic; + +@Path("multiple-auth-mech") +public class MultipleAuthMechanismResource { + + @Basic + @PermissionsAllowed("permission1") + @GET + @Path("basic") + public String basicAuthMech() { + return "basicAuthMech"; + } + + @CodeFlow + @GET + @Path("code-flow") + public String codeFlowAuthMech() { + return "codeFlowAuthMech"; + } +} diff --git a/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/CodeFlowTest.java b/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/CodeFlowTest.java index 8d55c8193dc060..9f20ea6d100075 100644 --- a/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/CodeFlowTest.java +++ b/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/CodeFlowTest.java @@ -21,6 +21,7 @@ import javax.crypto.spec.SecretKeySpec; import org.hamcrest.Matchers; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; @@ -1430,6 +1431,51 @@ public void testCustomLogin() throws Exception { } } + @Test + public void testBasicAuthAndCodeFlow() throws Exception { + // assert that endpoint annotated with a @Basic is only accessible with a Basic auth mechanism + RestAssured.given().auth().preemptive().basic("admin", "admin").header("custom", "custom") + .get("http://localhost:8081/multiple-auth-mech/basic").then().statusCode(200) + .body(Matchers.is("basicAuthMech")); + boolean codeFlowAuthFailed = false; + try (final WebClient webClient = createWebClient()) { + HtmlPage page = webClient.getPage("http://localhost:8081/multiple-auth-mech/basic"); + assertEquals("Sign in to quarkus", page.getTitleText()); + HtmlForm loginForm = page.getForms().get(0); + + loginForm.getInputByName("username").setValueAttribute("alice"); + loginForm.getInputByName("password").setValueAttribute("alice"); + + webClient.getOptions().setRedirectEnabled(false); + page = loginForm.getInputByName("login").click(); + + assertEquals("alice", page.getBody().asNormalizedText()); + } catch (FailingHttpStatusCodeException e) { + codeFlowAuthFailed = true; + } + if (!codeFlowAuthFailed) { + Assertions.fail("Endpoint 'basic' is annotated with the @Basic annotation, code flow auth should fail"); + } + + // assert that endpoint annotated with a @CodeFlow is only accessible with a CodeFlow auth mechanism + RestAssured.given().auth().preemptive().basic("admin", "admin").header("custom", "custom") + .get("http://localhost:8081/multiple-auth-mech/code-flow").then().statusCode(200) + .body(Matchers.containsString("Sign in to your account")); + try (final WebClient webClient = createWebClient()) { + HtmlPage page = webClient.getPage("http://localhost:8081/multiple-auth-mech/code-flow"); + assertEquals("Sign in to quarkus", page.getTitleText()); + HtmlForm loginForm = page.getForms().get(0); + + loginForm.getInputByName("username").setValueAttribute("alice"); + loginForm.getInputByName("password").setValueAttribute("alice"); + + page = loginForm.getInputByName("login").click(); + + assertEquals("codeFlowAuthMech", page.getBody().asNormalizedText()); + webClient.getCookieManager().clearCookies(); + } + } + private WebClient createWebClient() { WebClient webClient = new WebClient(); webClient.setCssErrorHandler(new SilentCssErrorHandler()); diff --git a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantResource.java b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantResource.java index bebdc001346df9..132c172b22ea1c 100644 --- a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantResource.java +++ b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantResource.java @@ -15,6 +15,8 @@ import io.quarkus.arc.Arc; import io.quarkus.oidc.AccessTokenCredential; +import io.quarkus.oidc.Bearer; +import io.quarkus.oidc.CodeFlow; import io.quarkus.oidc.IdToken; import io.quarkus.oidc.OIDCException; import io.quarkus.oidc.OidcSession; @@ -105,6 +107,22 @@ public String userNameServiceNoDiscovery(@PathParam("tenant") String tenant) { return userNameService(tenant, false); } + @CodeFlow + @GET + @Path("code-flow-auth-mech-annotation") + @RolesAllowed("user") + public String codeFlowAuthMechSelectedExplicitly() { + return "code-flow-auth-mech-annotation"; + } + + @Bearer + @GET + @Path("bearer-auth-mech-annotation") + @RolesAllowed("user") + public String bearerAuthMechSelectedExplicitly() { + return "bearer-auth-mech-annotation"; + } + @GET @Path("webapp") @RolesAllowed("user") diff --git a/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java b/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java index 73d7f7b646591e..a605177d16fa67 100644 --- a/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java +++ b/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java @@ -15,6 +15,8 @@ import java.util.concurrent.Callable; import java.util.concurrent.TimeUnit; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import com.gargoylesoftware.htmlunit.FailingHttpStatusCodeException; @@ -676,6 +678,50 @@ public void testOpaqueTokenScopePermission() { .statusCode(403); } + @Test + public void testAnnotationBasedAuthMechSelection() throws IOException { + // endpoint is annotated with @CodeFlow + try (final WebClient webClient = createWebClient()) { + HtmlPage page = webClient + .getPage("http://localhost:8081/tenant/tenant-web-app-dynamic/api/user/code-flow-auth-mech-annotation"); + assertEquals("Sign in to quarkus-webapp", page.getTitleText()); + HtmlForm loginForm = page.getForms().get(0); + loginForm.getInputByName("username").setValueAttribute("alice"); + loginForm.getInputByName("password").setValueAttribute("alice"); + page = loginForm.getInputByName("login").click(); + assertEquals("code-flow-auth-mech-annotation", page.getBody().asNormalizedText()); + webClient.getCookieManager().clearCookies(); + } + RestAssured.given().auth().oauth2(getAccessTokenFromSimpleOidc("1")) + .when().get("/tenant/tenant-oidc-no-discovery/api/user/code-flow-auth-mech-annotation") + .then() + .statusCode(401); + + // endpoint is annotated with @Bearer + RestAssured.given().auth().oauth2(getAccessTokenFromSimpleOidc("1")) + .when().get("/tenant/tenant-oidc-no-discovery/api/user/bearer-auth-mech-annotation") + .then() + .statusCode(200) + .body(Matchers.is("bearer-auth-mech-annotation")); + boolean codeFlowAuthFailed = false; + try (final WebClient webClient = createWebClient()) { + HtmlPage page = webClient + .getPage("http://localhost:8081/tenant/tenant-web-app-dynamic/api/user/bearer-auth-mech-annotation"); + assertEquals("Sign in to quarkus-webapp", page.getTitleText()); + HtmlForm loginForm = page.getForms().get(0); + loginForm.getInputByName("username").setValueAttribute("alice"); + loginForm.getInputByName("password").setValueAttribute("alice"); + webClient.getOptions().setRedirectEnabled(false); + loginForm.getInputByName("login").click(); + } catch (FailingHttpStatusCodeException e) { + codeFlowAuthFailed = true; + } + if (!codeFlowAuthFailed) { + Assertions.fail( + "Endpoint 'bearer-auth-mech-annotation' is annotated with the @Bearer annotation, code flow auth should fail"); + } + } + private String getAccessToken(String userName, String clientId) { return getAccessToken(userName, clientId, clientId); }