diff --git a/docs/src/main/asciidoc/security-authentication-mechanisms.adoc b/docs/src/main/asciidoc/security-authentication-mechanisms.adoc index 9584b86abb792b..a0f35dffc8eb6f 100644 --- a/docs/src/main/asciidoc/security-authentication-mechanisms.adoc +++ b/docs/src/main/asciidoc/security-authentication-mechanisms.adoc @@ -480,6 +480,87 @@ 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 + +# combine HTTP permissions with the endpoint-specific authentication mechanism +quarkus.http.auth.policy.roles1.roles-allowed=admin +quarkus.http.auth.permission.roles1.paths=/oidc/http-perms-with-code-flow +quarkus.http.auth.permission.roles1.applies-to=JAXRS <1> +quarkus.http.auth.permission.roles1.policy=roles1 +quarkus.http.auth.permission.roles1.methods=GET <2> +---- +<1> Delay this policy's permission check after the endpoint-specific authentication mechanism has been selected. +<2> Make the `roles1` permission match only the endpoint annotated with the `@AuthorizationCodeFlow` annotation. +Unannotated endpoints must avoid the delay caused by the `applies-to=JAXRS` option. + +[source,java] +---- +import io.quarkus.oidc.BearerTokenAuthentication; +import io.quarkus.oidc.AuthorizationCodeFlow; +import jakarta.annotation.security.RolesAllowed; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +@Path("oidc") +public class OidcResource1 { + + @GET + @BearerTokenAuthentication <1> <2> + @Path("bearer") + public String bearerAuthMechanism() { + return "bearer"; + } + + @GET + @RolesAllowed("admin") <3> + @AuthorizationCodeFlow <4> + @Path("code-flow") + public String codeFlowAuthMechanism() { + return "code-flow"; + } + + @GET + @AuthorizationCodeFlow <5> + @Path("http-perms-with-code-flow") + public String httpPermsWithCodeFlow() { + return "unreachable"; + } +} +---- +<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 `@BearerTokenAuthentication` annotation, the `@Authenticated` annotation is added by default. +<3> The `@AuthorizationCodeFlow` 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]. +<5> The HTTP permission `roles1` secures the `/oidc/http-perms-with-code-flow` request path, therefore the `admin` role is required. + +.Supported authentication mechanism annotations +|=== +^|Authentication mechanism^| Annotation + +s|Basic authentication mechanism ^|`io.quarkus.vertx.http.runtime.security.annotation.BasicAuthentication` +s|Form-based authentication mechanism ^|`io.quarkus.vertx.http.runtime.security.annotation.FormAuthentication` +s|Mutual TLS authentication mechanism ^|`io.quarkus.vertx.http.runtime.security.annotation.MTLSAuthentication` +s|WebAuthn authentication mechanism ^|`io.quarkus.security.webauthn.WebAuthn` +s|Bearer token authentication mechanism ^|`io.quarkus.oidc.BearerTokenAuthentication` +s|OIDC authorization code flow mechanism ^|`io.quarkus.oidc.AuthorizationCodeFlow` +|=== + +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.annotation.HttpAuthenticationMechanism` annotation to select any authentication mechanism based on its scheme. +Annotation-based analogy to the `quarkus.http.auth.permission.basic.auth-mechanism=custom` configuration property is the `@HttpAuthenticationMechanism("custom")` annotation. + +NOTE: For consistency with various Jakarta EE specifications, it is recommended to always repeat annotations instead of relying on annotation inheritance. + +[[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 c3e7934b780bc0..02254a9ac2917a 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 @@ -2,27 +2,22 @@ import static io.quarkus.arc.processor.BuiltinScope.APPLICATION; import static io.quarkus.arc.processor.DotNames.DEFAULT; +import static io.quarkus.oidc.common.runtime.OidcConstants.BEARER_SCHEME; +import static io.quarkus.oidc.common.runtime.OidcConstants.CODE_FLOW_CODE; import static io.quarkus.oidc.runtime.OidcUtils.DEFAULT_TENANT_ID; -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 java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Set; 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.eclipse.microprofile.jwt.JsonWebToken; -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; @@ -44,6 +39,8 @@ import io.quarkus.deployment.builditem.ExtensionSslNativeSupportBuildItem; import io.quarkus.deployment.builditem.RunTimeConfigurationDefaultBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; +import io.quarkus.oidc.AuthorizationCodeFlow; +import io.quarkus.oidc.BearerTokenAuthentication; import io.quarkus.oidc.IdToken; import io.quarkus.oidc.Tenant; import io.quarkus.oidc.TenantFeature; @@ -67,14 +64,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 { @@ -232,70 +229,17 @@ public SyntheticBeanBuildItem setup( @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(recorder.tenantResolverInterceptorCreator(), TENANT_NAME)); } } } @@ -322,6 +266,13 @@ void detectAccessTokenVerificationRequired(BeanRegistrationPhaseBuildItem beanRe } } + @BuildStep + List registerHttpAuthMechanismAnnotation() { + return List.of( + new HttpAuthMechanismAnnotationBuildItem(DotName.createSimple(AuthorizationCodeFlow.class), CODE_FLOW_CODE), + new HttpAuthMechanismAnnotationBuildItem(DotName.createSimple(BearerTokenAuthentication.class), BEARER_SCHEME)); + } + private static boolean isInjected(BeanRegistrationPhaseBuildItem beanRegistrationPhaseBuildItem, DotName requiredType, DotName withoutQualifier) { for (InjectionPointInfo injectionPoint : beanRegistrationPhaseBuildItem.getInjectionPoints()) { diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/AuthorizationCodeFlow.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/AuthorizationCodeFlow.java new file mode 100644 index 00000000000000..6653f748427b53 --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/AuthorizationCodeFlow.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.annotation.HttpAuthenticationMechanism; + +/** + * Selects {@link CodeAuthenticationMechanism}. + * + * @see HttpAuthenticationMechanism for more information + */ +@HttpAuthenticationMechanism("code") +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE, ElementType.METHOD }) +public @interface AuthorizationCodeFlow { + +} diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/BearerTokenAuthentication.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/BearerTokenAuthentication.java new file mode 100644 index 00000000000000..a4c284e63a76f4 --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/BearerTokenAuthentication.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.annotation.HttpAuthenticationMechanism; + +/** + * Selects {@link io.quarkus.oidc.runtime.BearerAuthenticationMechanism}. + * + * @see HttpAuthenticationMechanism for more information + */ +@HttpAuthenticationMechanism("Bearer") +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE, ElementType.METHOD }) +public @interface BearerTokenAuthentication { + +} 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 3b204d32d77ac5..f67d7f851f41d5 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 @@ -41,7 +41,6 @@ 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; @@ -49,7 +48,6 @@ import io.quarkus.security.identity.SecurityIdentity; import io.quarkus.security.identity.request.TokenAuthenticationRequest; import io.quarkus.security.spi.runtime.BlockingSecurityExecutor; -import io.quarkus.security.spi.runtime.MethodDescription; import io.quarkus.security.spi.runtime.SecurityEventHelper; import io.smallrye.jwt.algorithm.KeyEncryptionAlgorithm; import io.smallrye.jwt.util.KeyUtils; @@ -107,10 +105,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) { @@ -599,14 +593,19 @@ private static boolean fireOidcServerEvent(String authServerUrl, SecurityEvent.T return false; } - public Consumer createTenantResolverInterceptor(String tenantId) { - return new Consumer() { + public Function> tenantResolverInterceptorCreator() { + return new Function>() { @Override - public void accept(RoutingContext routingContext) { - LOG.debugf("@Tenant annotation set a '%s' tenant id on the %s request path", tenantId, - routingContext.request().path()); - routingContext.put(OidcUtils.TENANT_ID_SET_BY_ANNOTATION, tenantId); - routingContext.put(OidcUtils.TENANT_ID_ATTRIBUTE, tenantId); + public Consumer apply(String tenantId) { + return new Consumer() { + @Override + public void accept(RoutingContext routingContext) { + LOG.debugf("@Tenant annotation set a '%s' tenant id on the %s request path", tenantId, + routingContext.request().path()); + routingContext.put(OidcUtils.TENANT_ID_SET_BY_ANNOTATION, tenantId); + routingContext.put(OidcUtils.TENANT_ID_ATTRIBUTE, tenantId); + } + }; } }; } 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..2e08b913e890d6 --- /dev/null +++ b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/AnnotationBasedAuthMechanismSelectionTest.java @@ -0,0 +1,378 @@ +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.BasicAuthenticationMechanism; +import io.quarkus.vertx.http.runtime.security.ChallengeData; +import io.quarkus.vertx.http.runtime.security.HttpCredentialTransport; +import io.quarkus.vertx.http.runtime.security.annotation.BasicAuthentication; +import io.quarkus.vertx.http.runtime.security.annotation.FormAuthentication; +import io.quarkus.vertx.http.runtime.security.annotation.HttpAuthenticationMechanism; +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(), + new AuthMechRequest("annotated-http-permissions/roles-allowed-jax-rs-policy").form()); + + @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 + quarkus.http.auth.form.enabled=true + quarkus.http.auth.form.login-page= + quarkus.http.auth.form.error-page= + quarkus.http.auth.form.landing-page= + quarkus.http.auth.basic=true + quarkus.http.auth.permission.roles1.paths=/annotated-http-permissions/roles-allowed,/unannotated-http-permissions/roles-allowed + quarkus.http.auth.permission.roles1.policy=roles1 + quarkus.http.auth.permission.jax-rs.paths=/annotated-http-permissions/roles-allowed-jax-rs-policy + quarkus.http.auth.permission.jax-rs.policy=roles1 + quarkus.http.auth.permission.jax-rs.applies-to=JAXRS + quarkus.http.auth.policy.roles1.roles-allowed=admin + quarkus.http.auth.permission.authenticated.auth-mechanism=basic + quarkus.http.auth.permission.authenticated.paths=/annotated-http-permissions/authenticated,/unannotated-http-permissions/authenticated + quarkus.http.auth.permission.authenticated.policy=authenticated + quarkus.http.auth.permission.same-mechanism.paths=/annotated-http-permissions/same-mech + quarkus.http.auth.permission.same-mechanism.policy=authenticated + quarkus.http.auth.permission.same-mechanism.auth-mechanism=custom + quarkus.http.auth.permission.diff-mechanism.paths=/annotated-http-permissions/diff-mech + quarkus.http.auth.permission.diff-mechanism.policy=authenticated + quarkus.http.auth.permission.diff-mechanism.auth-mechanism=basic + quarkus.http.auth.permission.permit1.paths=/annotated-http-permissions/permit,/unannotated-http-permissions/permit + quarkus.http.auth.permission.permit1.policy=permit + quarkus.http.auth.permission.deny1.paths=/annotated-http-permissions/deny,/unannotated-http-permissions/deny + quarkus.http.auth.permission.deny1.policy=deny + """), + "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); + + requestWithFormAuth("admin").get("/annotated-http-permissions/roles-allowed").then().statusCode(200); + requestWithFormAuth("admin").get("/unannotated-http-permissions/roles-allowed").then().statusCode(200); + requestWithFormAuth("user").get("/unannotated-http-permissions/authenticated").then().statusCode(401); + + // 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); + } + + @Test + public void testBothHttpSecPolicyAndAnnotationApplied() { + // HTTP policy requires basic, but resource method inherits class-level `@Form` annotation + requestWithBasicAuthUser().get("/annotated-http-permissions/authenticated").then().statusCode(401); + requestWithFormAuth("user").get("/annotated-http-permissions/authenticated").then().statusCode(401); + // send both form & basic credentials + requestWithFormAuth("user").auth().preemptive().basic("admin", "admin").get("/annotated-http-permissions/authenticated") + .then().statusCode(401); + } + + @Test + public void testAuthenticatedHttpPolicyUsingSameMechanism() { + requestWithBasicAuthUser().get("/annotated-http-permissions/same-mech").then().statusCode(200); + } + + @Test + public void testAuthenticatedHttpPolicyUsingDiffMechanism() { + requestWithBasicAuthUser().get("/annotated-http-permissions/diff-mech").then().statusCode(401); + } + + private static IntStream authMechanismRequestsIdxs() { + return IntStream.range(0, REQUESTS.size()); + } + + @Path("unannotated-http-permissions") + public static class UnannotatedHttpPermissionsResource extends AbstractHttpPermissionsResource { + + @HttpAuthenticationMechanism("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") + @HttpAuthenticationMechanism("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"; + } + + @HttpAuthenticationMechanism("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 @HttpAuthenticationMechanism is applied, default form authentication is used + // also verifies @HttpAuthenticationMechanism on abstract class is not applied + return "form-default"; + } + + } + + @HttpAuthenticationMechanism("custom") // verifies that + @Path("annotated-http-permissions") + public static class AnnotatedHttpPermissionsResource extends AbstractAnnotatedHttpPermissionsResource { + + @Authenticated + @BasicAuthentication + @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"; + } + + @GET + @HttpAuthenticationMechanism("custom") + @Path("same-mech") + public String authPolicyIsUsingSameMechAsAnnotation() { + // policy uses custom mechanism and annotation selects custom mechanism as well + return "same-mech"; + } + + @GET + @HttpAuthenticationMechanism("custom") + @Path("diff-mech") + public String authPolicyIsUsingDiffMechAsAnnotation() { + // policy uses basic mechanism and annotation selects custom mechanism + return "diff-mech"; + } + } + + @FormAuthentication + 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("roles-allowed-jax-rs-policy") + @GET + public String rolesAllowedJaxRsPolicy() { + return "roles-allowed-jax-rs-policy"; + } + + @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") + @BasicAuthentication + @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"; + } + + @BasicAuthentication + @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 io.quarkus.vertx.http.runtime.security.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/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/rest/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveProcessor.java b/extensions/resteasy-reactive/rest/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveProcessor.java index 49026dc2197d4e..758060bfb65101 100644 --- a/extensions/resteasy-reactive/rest/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveProcessor.java +++ b/extensions/resteasy-reactive/rest/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveProcessor.java @@ -3,6 +3,7 @@ import static io.quarkus.resteasy.reactive.common.deployment.QuarkusResteasyReactiveDotNames.HTTP_SERVER_REQUEST; 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 io.quarkus.vertx.http.deployment.EagerSecurityInterceptorMethodsBuildItem.collectInterceptedMethods; import static java.util.stream.Collectors.toList; import static org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames.DATE_FORMAT; import static org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames.LEGACY_PUBLISHER; @@ -203,7 +204,7 @@ 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.EagerSecurityInterceptorMethodsBuildItem; import io.quarkus.vertx.http.deployment.FilterBuildItem; import io.quarkus.vertx.http.deployment.RouteBuildItem; import io.quarkus.vertx.http.runtime.HttpBuildTimeConfig; @@ -1496,13 +1497,13 @@ public void securityExceptionMappers(BuildProducer exc @BuildStep MethodScannerBuildItem integrateEagerSecurity(Capabilities capabilities, CombinedIndexBuildItem indexBuildItem, - HttpBuildTimeConfig httpBuildTimeConfig, Optional eagerSecurityInterceptors, - JaxRsSecurityConfig securityConfig) { + List eagerSecurityInterceptors, JaxRsSecurityConfig securityConfig) { if (!capabilities.isPresent(Capability.SECURITY)) { return null; } - final boolean applySecurityInterceptors = eagerSecurityInterceptors.isPresent(); + final boolean applySecurityInterceptors = !eagerSecurityInterceptors.isEmpty(); + final var interceptedMethods = applySecurityInterceptors ? collectInterceptedMethods(eagerSecurityInterceptors) : null; final boolean denyJaxRs = securityConfig.denyJaxRs(); final boolean hasDefaultJaxRsRolesAllowed = !securityConfig.defaultRolesAllowed().orElse(List.of()).isEmpty(); var index = indexBuildItem.getComputingIndex(); @@ -1510,22 +1511,21 @@ MethodScannerBuildItem integrateEagerSecurity(Capabilities capabilities, Combine @Override public List scan(MethodInfo method, ClassInfo actualEndpointClass, Map methodContext) { - if (applySecurityInterceptors && eagerSecurityInterceptors.get().applyInterceptorOn(method)) { + if (applySecurityInterceptors && interceptedMethods.contains(method)) { // EagerSecurityHandler needs to be present whenever the method requires eager interceptor // because JAX-RS specific HTTP Security policies are defined by runtime config properties // for example: when you annotate resource method with @Tenant("hr") you select OIDC tenant, // so we can't authenticate before the tenant is selected, only after then HTTP perms can be checked return List.of(EagerSecurityInterceptorHandler.Customizer.newInstance(), - EagerSecurityHandler.Customizer.newInstance(httpBuildTimeConfig.auth.proactive)); + EagerSecurityHandler.Customizer.newInstance()); } else { if (denyJaxRs || hasDefaultJaxRsRolesAllowed) { - return List.of(EagerSecurityHandler.Customizer.newInstance(httpBuildTimeConfig.auth.proactive)); + return List.of(EagerSecurityHandler.Customizer.newInstance()); } else { return Objects .requireNonNullElse( consumeStandardSecurityAnnotations(method, actualEndpointClass, index, - (c) -> Collections.singletonList(EagerSecurityHandler.Customizer - .newInstance(httpBuildTimeConfig.auth.proactive))), + (c) -> List.of(EagerSecurityHandler.Customizer.newInstance())), Collections.emptyList()); } } diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/AnnotationBasedAuthMechanismSelectionTest.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/AnnotationBasedAuthMechanismSelectionTest.java new file mode 100644 index 00000000000000..ae76a6e986814f --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/AnnotationBasedAuthMechanismSelectionTest.java @@ -0,0 +1,476 @@ +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.BasicAuthenticationMechanism; +import io.quarkus.vertx.http.runtime.security.ChallengeData; +import io.quarkus.vertx.http.runtime.security.HttpCredentialTransport; +import io.quarkus.vertx.http.runtime.security.annotation.BasicAuthentication; +import io.quarkus.vertx.http.runtime.security.annotation.FormAuthentication; +import io.quarkus.vertx.http.runtime.security.annotation.HttpAuthenticationMechanism; +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(), + new AuthMechRequest("annotated-http-permissions/roles-allowed-jax-rs-policy").form()); + + @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 + quarkus.http.auth.form.enabled=true + quarkus.http.auth.form.login-page= + quarkus.http.auth.form.error-page= + quarkus.http.auth.form.landing-page= + quarkus.http.auth.basic=true + quarkus.http.auth.permission.roles1.paths=/annotated-http-permissions/roles-allowed,/unannotated-http-permissions/roles-allowed + quarkus.http.auth.permission.roles1.policy=roles1 + quarkus.http.auth.permission.jax-rs.paths=/annotated-http-permissions/roles-allowed-jax-rs-policy + quarkus.http.auth.permission.jax-rs.policy=roles1 + quarkus.http.auth.permission.jax-rs.applies-to=JAXRS + quarkus.http.auth.policy.roles1.roles-allowed=admin + quarkus.http.auth.permission.authenticated.auth-mechanism=basic + quarkus.http.auth.permission.authenticated.paths=/annotated-http-permissions/authenticated,/unannotated-http-permissions/authenticated + quarkus.http.auth.permission.authenticated.policy=authenticated + quarkus.http.auth.permission.same-mechanism.paths=/annotated-http-permissions/same-mech + quarkus.http.auth.permission.same-mechanism.policy=authenticated + quarkus.http.auth.permission.same-mechanism.auth-mechanism=custom + quarkus.http.auth.permission.diff-mechanism.paths=/annotated-http-permissions/diff-mech + quarkus.http.auth.permission.diff-mechanism.policy=authenticated + quarkus.http.auth.permission.diff-mechanism.auth-mechanism=basic + quarkus.http.auth.permission.permit1.paths=/annotated-http-permissions/permit,/unannotated-http-permissions/permit + quarkus.http.auth.permission.permit1.policy=permit + quarkus.http.auth.permission.deny1.paths=/annotated-http-permissions/deny,/unannotated-http-permissions/deny + quarkus.http.auth.permission.deny1.policy=deny + """), + "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).statusCode(401); + } + } + } + + @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); + + requestWithFormAuth("admin").get("/annotated-http-permissions/roles-allowed").then().statusCode(200); + requestWithFormAuth("admin").get("/unannotated-http-permissions/roles-allowed").then().statusCode(200); + requestWithFormAuth("user").get("/unannotated-http-permissions/authenticated").then().statusCode(401); + + // 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); + } + + @Test + public void testBothHttpSecPolicyAndAnnotationApplied() { + // here we test HTTP Security policy applied to all the paths that runs before annotation is matched + // HTTP policy requires basic, but resource method inherits class-level `@Form` annotation + requestWithBasicAuthUser().get("/annotated-http-permissions/authenticated").then().statusCode(401); + requestWithFormAuth("user").get("/annotated-http-permissions/authenticated").then().statusCode(401); + // send both form & basic credentials + requestWithFormAuth("user").auth().preemptive().basic("admin", "admin").get("/annotated-http-permissions/authenticated") + .then().statusCode(401); + } + + @Test + public void testAuthenticatedHttpPolicyUsingSameMechanism() { + requestWithBasicAuthUser().get("/annotated-http-permissions/same-mech").then().statusCode(200); + } + + @Test + public void testAuthenticatedHttpPolicyUsingDiffMechanism() { + // HTTP Security policy applied on all the paths (not just JAX-RS ones) tries to authenticate with a different + // authentication mechanism than is selected with the annotation, therefore we deny request + requestWithBasicAuthUser().get("/annotated-http-permissions/diff-mech").then().statusCode(401); + } + + private static IntStream authMechanismRequestsIdxs() { + return IntStream.range(0, REQUESTS.size()); + } + + @Path("unannotated-http-permissions") + public static class UnannotatedHttpPermissionsResource extends AbstractHttpPermissionsResource { + + @HttpAuthenticationMechanism("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") + @HttpAuthenticationMechanism("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"; + } + + @HttpAuthenticationMechanism("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 @HttpAuthenticationMechanism is applied, default form authentication is used + // also verifies @HttpAuthenticationMechanism on abstract class is not applied + return "form-default"; + } + + } + + @HttpAuthenticationMechanism("custom") // verifies that + @Path("annotated-http-permissions") + public static class AnnotatedHttpPermissionsResource extends AbstractAnnotatedHttpPermissionsResource { + + @Authenticated + @BasicAuthentication + @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(); + } + + @GET + @HttpAuthenticationMechanism("custom") + @Path("same-mech") + public String authPolicyIsUsingSameMechAsAnnotation() { + // policy uses custom mechanism and annotation selects custom mechanism as well + return "same-mech"; + } + + @GET + @HttpAuthenticationMechanism("custom") + @Path("diff-mech") + public String authPolicyIsUsingDiffMechAsAnnotation() { + // policy uses basic mechanism and annotation selects custom mechanism + return "diff-mech"; + } + } + + public interface HttpPermissionsResourceMethodLevelInterface { + + @Authenticated // by rules of CDI inheritance, this annotation is completely ignored + @BasicAuthentication + @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(); + } + } + + @BasicAuthentication + 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(); + } + } + + @FormAuthentication + 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("roles-allowed-jax-rs-policy") + @GET + public String rolesAllowedJaxRsPolicy() { + return "roles-allowed-jax-rs-policy"; + } + + @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") + @BasicAuthentication + @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"; + } + + @BasicAuthentication + @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 io.quarkus.vertx.http.runtime.security.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/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/AuthMechRequest.java b/extensions/resteasy-reactive/rest/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/rest/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/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/MtlsBasicAnnotationBasedAuthMechSelectionTest.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/MtlsBasicAnnotationBasedAuthMechSelectionTest.java new file mode 100644 index 00000000000000..aba63d4868ead1 --- /dev/null +++ b/extensions/resteasy-reactive/rest/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.annotation.BasicAuthentication; +import io.quarkus.vertx.http.runtime.security.annotation.MTLSAuthentication; +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; + + @MTLSAuthentication + @Path("mtls") + @GET + public String mtls() { + return identity.getPrincipal().getName(); + } + + @BasicAuthentication + @Path("basic") + @GET + public String basic() { + return identity.getPrincipal().getName(); + } + + } +} diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/TestTrustedIdentityProvider.java b/extensions/resteasy-reactive/rest/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/rest/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/rest/deployment/src/test/resources/mtls/client-keystore.jks b/extensions/resteasy-reactive/rest/deployment/src/test/resources/mtls/client-keystore.jks new file mode 100644 index 00000000000000..cf6d6ba454864d Binary files /dev/null and b/extensions/resteasy-reactive/rest/deployment/src/test/resources/mtls/client-keystore.jks differ diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/resources/mtls/client-truststore.jks b/extensions/resteasy-reactive/rest/deployment/src/test/resources/mtls/client-truststore.jks new file mode 100644 index 00000000000000..bf6371859c55fe Binary files /dev/null and b/extensions/resteasy-reactive/rest/deployment/src/test/resources/mtls/client-truststore.jks differ diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/resources/mtls/mtls-basic-jks.conf b/extensions/resteasy-reactive/rest/deployment/src/test/resources/mtls/mtls-basic-jks.conf new file mode 100644 index 00000000000000..e272f3f287d7b8 --- /dev/null +++ b/extensions/resteasy-reactive/rest/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 diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/resources/mtls/server-keystore.jks b/extensions/resteasy-reactive/rest/deployment/src/test/resources/mtls/server-keystore.jks new file mode 100644 index 00000000000000..da33e8e7a16683 Binary files /dev/null and b/extensions/resteasy-reactive/rest/deployment/src/test/resources/mtls/server-keystore.jks differ diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/resources/mtls/server-truststore.jks b/extensions/resteasy-reactive/rest/deployment/src/test/resources/mtls/server-truststore.jks new file mode 100644 index 00000000000000..8ec8e126507b61 Binary files /dev/null and b/extensions/resteasy-reactive/rest/deployment/src/test/resources/mtls/server-truststore.jks differ diff --git a/extensions/resteasy-reactive/rest/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/security/EagerSecurityContext.java b/extensions/resteasy-reactive/rest/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/security/EagerSecurityContext.java index be6b401fc498ab..5247327a1c37bc 100644 --- a/extensions/resteasy-reactive/rest/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/security/EagerSecurityContext.java +++ b/extensions/resteasy-reactive/rest/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/security/EagerSecurityContext.java @@ -18,6 +18,7 @@ import org.jboss.resteasy.reactive.server.core.ResteasyReactiveRequestContext; import org.jboss.resteasy.reactive.server.spi.ResteasyReactiveResourceInfo; +import io.quarkus.arc.Arc; import io.quarkus.arc.InjectableInstance; import io.quarkus.runtime.ShutdownEvent; import io.quarkus.runtime.StartupEvent; @@ -35,6 +36,7 @@ import io.quarkus.vertx.http.runtime.HttpBuildTimeConfig; import io.quarkus.vertx.http.runtime.HttpConfiguration; import io.quarkus.vertx.http.runtime.security.AbstractPathMatchingHttpSecurityPolicy; +import io.quarkus.vertx.http.runtime.security.EagerSecurityInterceptorStorage; import io.quarkus.vertx.http.runtime.security.HttpSecurityPolicy; import io.quarkus.vertx.http.runtime.security.HttpSecurityPolicy.DefaultAuthorizationRequestContext; import io.quarkus.vertx.http.runtime.security.QuarkusHttpUser; @@ -49,9 +51,11 @@ public class EagerSecurityContext { final AbstractPathMatchingHttpSecurityPolicy jaxRsPathMatchingPolicy; final SecurityEventHelper eventHelper; final InjectableInstance identityAssociation; + final EagerSecurityInterceptorStorage interceptorStorage; final AuthorizationController authorizationController; final SecurityCheckStorage securityCheckStorage; final boolean doNotRunPermissionSecurityCheck; + final boolean isProactiveAuthDisabled; EagerSecurityContext(Event authorizationFailureEvent, @ConfigProperty(name = "quarkus.security.events.enabled") boolean securityEventsEnabled, @@ -59,6 +63,9 @@ public class EagerSecurityContext { InjectableInstance identityAssociation, AuthorizationController authorizationController, SecurityCheckStorage securityCheckStorage, HttpConfiguration httpConfig, BlockingSecurityExecutor blockingExecutor, HttpBuildTimeConfig buildTimeConfig, Instance installedPolicies) { + var interceptorStorageHandle = Arc.container().instance(EagerSecurityInterceptorStorage.class); + this.interceptorStorage = interceptorStorageHandle.isAvailable() ? interceptorStorageHandle.get() : null; + this.isProactiveAuthDisabled = !buildTimeConfig.auth.proactive; this.identityAssociation = identityAssociation; this.authorizationController = authorizationController; this.securityCheckStorage = securityCheckStorage; diff --git a/extensions/resteasy-reactive/rest/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/security/EagerSecurityHandler.java b/extensions/resteasy-reactive/rest/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/security/EagerSecurityHandler.java index abb4cff2408561..65d62e24c62f97 100644 --- a/extensions/resteasy-reactive/rest/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/security/EagerSecurityHandler.java +++ b/extensions/resteasy-reactive/rest/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/security/EagerSecurityHandler.java @@ -40,13 +40,8 @@ public void apply(SecurityIdentity identity, MethodDescription method, Object[] } }; - private final boolean isProactiveAuthDisabled; private volatile SecurityCheck check; - public EagerSecurityHandler(boolean isProactiveAuthDisabled) { - this.isProactiveAuthDisabled = isProactiveAuthDisabled; - } - @Override public void handle(ResteasyReactiveRequestContext requestContext) throws Exception { if (!EagerSecurityContext.instance.authorizationController.isAuthorizationEnabled()) { @@ -135,7 +130,7 @@ private Function> getSecurityCheck(ResteasyReactiveRequ return new Function>() { @Override public Uni apply(SecurityIdentity securityIdentity) { - if (isProactiveAuthDisabled) { + if (EagerSecurityContext.instance.isProactiveAuthDisabled) { // if proactive auth is disabled, then accessing SecurityIdentity would be a blocking // operation if we don't set it; this will allow to access the identity without blocking // from secured endpoints @@ -215,38 +210,20 @@ private static boolean isRequestAlreadyChecked(ResteasyReactiveRequestContext re return requestContext.getProperty(STANDARD_SECURITY_CHECK_INTERCEPTOR) != null; } - public static abstract class Customizer implements HandlerChainCustomizer { + public static class Customizer implements HandlerChainCustomizer { - public static HandlerChainCustomizer newInstance(boolean isProactiveAuthEnabled) { - return isProactiveAuthEnabled ? new ProactiveAuthEnabledCustomizer() : new ProactiveAuthDisabledCustomizer(); + public static HandlerChainCustomizer newInstance() { + return new Customizer(); } - protected abstract boolean isProactiveAuthDisabled(); - @Override public List handlers(Phase phase, ResourceClass resourceClass, ServerResourceMethod serverResourceMethod) { if (phase == Phase.AFTER_MATCH) { - return Collections.singletonList(new EagerSecurityHandler(isProactiveAuthDisabled())); + return Collections.singletonList(new EagerSecurityHandler()); } return Collections.emptyList(); } - public static class ProactiveAuthEnabledCustomizer extends Customizer { - - @Override - protected boolean isProactiveAuthDisabled() { - return false; - } - } - - public static class ProactiveAuthDisabledCustomizer extends Customizer { - - @Override - protected boolean isProactiveAuthDisabled() { - return true; - } - } - } } diff --git a/extensions/resteasy-reactive/rest/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/security/EagerSecurityInterceptorHandler.java b/extensions/resteasy-reactive/rest/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/security/EagerSecurityInterceptorHandler.java index 1a68d697d85c25..6570c4ea403bbf 100644 --- a/extensions/resteasy-reactive/rest/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/security/EagerSecurityInterceptorHandler.java +++ b/extensions/resteasy-reactive/rest/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/security/EagerSecurityInterceptorHandler.java @@ -12,10 +12,8 @@ import org.jboss.resteasy.reactive.server.model.ServerResourceMethod; import org.jboss.resteasy.reactive.server.spi.ServerRestHandler; -import io.quarkus.arc.Arc; import io.quarkus.security.spi.runtime.MethodDescription; import io.quarkus.security.spi.runtime.SecurityCheck; -import io.quarkus.vertx.http.runtime.security.EagerSecurityInterceptorStorage; import io.vertx.ext.web.RoutingContext; /** @@ -46,7 +44,7 @@ public void handle(ResteasyReactiveRequestContext requestContext) throws Excepti if (interceptor == null) { MethodDescription methodDescription = lazyMethodToMethodDescription(requestContext.getTarget().getLazyMethod()); - interceptor = Arc.container().select(EagerSecurityInterceptorStorage.class).get().getInterceptor(methodDescription); + interceptor = EagerSecurityContext.instance.interceptorStorage.getInterceptor(methodDescription); if (interceptor == null) { interceptor = NULL_SENTINEL; diff --git a/extensions/security-webauthn/deployment/pom.xml b/extensions/security-webauthn/deployment/pom.xml index 617b77e8b75fd8..752d31b79ef414 100644 --- a/extensions/security-webauthn/deployment/pom.xml +++ b/extensions/security-webauthn/deployment/pom.xml @@ -48,6 +48,11 @@ quarkus-test-security-webauthn test + + io.quarkus + quarkus-security-test-utils + test + 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 277fa595c6b5d0..9a12d8e4e9f557 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.AUTH_MECHANISM_SCHEME)); + } + 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..5b32c702aa156f --- /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.annotation.BasicAuthentication; +import io.smallrye.mutiny.Uni; + +@Path("/multiple-auth-mech") +public class MultipleAuthMechResource { + + @BasicAuthentication + @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..ced7d44860ff19 --- /dev/null +++ b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnAndBasicAuthnTest.java @@ -0,0 +1,108 @@ +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.WebAuthnRunTimeConfig; +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.smallrye.config.SmallRyeConfigBuilder; +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); + var config = new SmallRyeConfigBuilder() + .withMapping(WebAuthnRunTimeConfig.class) + .build() + .getConfigMapping(WebAuthnRunTimeConfig.class); + request + .post("/register") + .then().statusCode(200) + .body(Matchers.is("OK")) + .cookie(config.challengeCookieName(), Matchers.is("")) + .cookie(config.challengeUsernameCookieName(), 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..f71f7594ba6523 --- /dev/null +++ b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthn.java @@ -0,0 +1,22 @@ +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.annotation.HttpAuthenticationMechanism; + +/** + * Selects {@link WebAuthnAuthenticationMechanism}. + * + * @see HttpAuthenticationMechanism for more information + */ +@HttpAuthenticationMechanism("webauthn") +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE, ElementType.METHOD }) +public @interface WebAuthn { + + String AUTH_MECHANISM_SCHEME = "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..6ed0ef49744c37 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 @@ -1,5 +1,7 @@ package io.quarkus.security.webauthn; +import static io.quarkus.security.webauthn.WebAuthn.AUTH_MECHANISM_SCHEME; + import java.util.Arrays; import java.util.HashSet; import java.util.Set; @@ -72,7 +74,7 @@ public Set> getCredentialTypes() { @Override public Uni getCredentialTransport(RoutingContext context) { - return Uni.createFrom().nullItem(); + return Uni.createFrom().item(new HttpCredentialTransport(HttpCredentialTransport.Type.COOKIE, AUTH_MECHANISM_SCHEME)); } public PersistentLoginManager getLoginManager() { diff --git a/extensions/vertx-http/deployment/pom.xml b/extensions/vertx-http/deployment/pom.xml index 743773b44f0a83..2934d531c308c1 100644 --- a/extensions/vertx-http/deployment/pom.xml +++ b/extensions/vertx-http/deployment/pom.xml @@ -37,6 +37,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..9cb335d53b863f --- /dev/null +++ b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/EagerSecurityInterceptorBindingBuildItem.java @@ -0,0 +1,76 @@ +package io.quarkus.vertx.http.deployment; + +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.jboss.jandex.AnnotationInstance; +import org.jboss.jandex.AnnotationTarget; +import org.jboss.jandex.DotName; +import org.jboss.jandex.MethodInfo; + +import io.quarkus.builder.item.MultiBuildItem; +import io.quarkus.runtime.configuration.ConfigurationException; +import io.quarkus.vertx.http.runtime.security.annotation.HttpAuthenticationMechanism; +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 HttpAuthenticationMechanism} + * and an interceptor that sets annotation value ('@HttpAuthenticationMechanism("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[] annotationBindings; + private final Function> interceptorCreator; + private final Map bindingToValue; + + /** + * + * @param interceptorBindings annotation names, '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(Function> interceptorCreator, + DotName... interceptorBindings) { + this.annotationBindings = interceptorBindings; + this.interceptorCreator = interceptorCreator; + this.bindingToValue = Map.of(); + } + + EagerSecurityInterceptorBindingBuildItem(Function> interceptorCreator, + Map bindingToValue, DotName... interceptorBindings) { + this.annotationBindings = interceptorBindings; + this.interceptorCreator = interceptorCreator; + this.bindingToValue = bindingToValue; + } + + public DotName[] getAnnotationBindings() { + return annotationBindings; + } + + Function> getInterceptorCreator() { + return interceptorCreator; + } + + public String getBindingValue(AnnotationInstance annotationInstance, DotName annotation, MethodInfo classEndpoint) { + if (bindingToValue.containsKey(annotation.toString())) { + return bindingToValue.get(annotation.toString()); + } + if (annotationInstance.value() == null || annotationInstance.value().asString().isBlank()) { + throw new ConfigurationException("Annotation '" + annotation + "' placed on '" + + toTargetName(classEndpoint) + "' must not have blank value"); + } + return annotationInstance.value().asString(); + } + + 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(); + } + } +} 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..2ac04307f28699 --- /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.Stream; + +import org.jboss.jandex.DotName; +import org.jboss.jandex.MethodInfo; + +import io.quarkus.builder.item.MultiBuildItem; +import io.quarkus.vertx.http.runtime.security.annotation.HttpAuthenticationMechanism; + +/** + * Bears collected intercepted methods annotated with registered security annotation. + * Security interceptor needs to be created and applied for each intercepted method. + * + * @see EagerSecurityInterceptorBindingBuildItem for more information on security filters + */ +public final class EagerSecurityInterceptorMethodsBuildItem extends MultiBuildItem { + + /** + * Annotation binding value: '@HttpAuthenticationMechanism("custom")' => 'custom'; mapped to annotated methods + */ + final Map> bindingValueToInterceptedMethods; + + /** + * Interceptor binding annotation name, like {@link HttpAuthenticationMechanism}. + */ + final DotName interceptorBinding; + + EagerSecurityInterceptorMethodsBuildItem(Map> bindingValueToInterceptedMethods, + DotName interceptorBinding) { + this.bindingValueToInterceptedMethods = Map.copyOf(bindingValueToInterceptedMethods); + this.interceptorBinding = interceptorBinding; + } + + private Stream interceptedMethods() { + return bindingValueToInterceptedMethods.values().stream().flatMap(Collection::stream); + } + + public static List collectInterceptedMethods(List items) { + return items.stream().flatMap(EagerSecurityInterceptorMethodsBuildItem::interceptedMethods).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..124def975f710e --- /dev/null +++ b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/HttpAuthMechanismAnnotationBuildItem.java @@ -0,0 +1,30 @@ +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.annotation.BasicAuthentication; +import io.quarkus.vertx.http.runtime.security.annotation.HttpAuthenticationMechanism; + +/** + * Register {@link HttpAuthenticationMechanism} meta annotations. + * This way, users can use {@link BasicAuthentication} instead of '@HttpAuthenticationMechanism("basic")'. + */ +public final class HttpAuthMechanismAnnotationBuildItem extends MultiBuildItem { + + /** + * Annotation name, for example {@link BasicAuthentication}. + */ + final DotName annotationName; + /** + * Authentication mechanism scheme, as defined by {@link HttpAuthenticationMechanism#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 2375e244ddb282..a79eb04049dbdc 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 @@ -3,18 +3,31 @@ import static io.quarkus.arc.processor.DotNames.APPLICATION_SCOPED; import static io.quarkus.arc.processor.DotNames.DEFAULT_BEAN; import static io.quarkus.arc.processor.DotNames.SINGLETON; +import static java.util.stream.Collectors.toMap; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Arrays; +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.BooleanSupplier; import java.util.function.Consumer; import java.util.stream.Collectors; +import java.util.stream.Stream; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Singleton; +import org.jboss.jandex.AnnotationInstance; +import org.jboss.jandex.AnnotationTarget; +import org.jboss.jandex.ClassInfo; import org.jboss.jandex.DotName; +import org.jboss.jandex.IndexView; import org.jboss.jandex.MethodInfo; import io.quarkus.arc.deployment.AdditionalBeanBuildItem; @@ -27,7 +40,11 @@ import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.annotations.ExecutionTime; import io.quarkus.deployment.annotations.Record; +import io.quarkus.deployment.builditem.CombinedIndexBuildItem; import io.quarkus.runtime.RuntimeValue; +import io.quarkus.runtime.configuration.ConfigurationException; +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.HttpConfiguration; @@ -42,11 +59,17 @@ import io.quarkus.vertx.http.runtime.security.MtlsAuthenticationMechanism; import io.quarkus.vertx.http.runtime.security.PathMatchingHttpSecurityPolicy; import io.quarkus.vertx.http.runtime.security.VertxBlockingSecurityExecutor; +import io.quarkus.vertx.http.runtime.security.annotation.BasicAuthentication; +import io.quarkus.vertx.http.runtime.security.annotation.FormAuthentication; +import io.quarkus.vertx.http.runtime.security.annotation.HttpAuthenticationMechanism; +import io.quarkus.vertx.http.runtime.security.annotation.MTLSAuthentication; import io.vertx.core.http.ClientAuth; import io.vertx.ext.web.RoutingContext; public class HttpSecurityProcessor { + private static final DotName AUTH_MECHANISM_NAME = DotName.createSimple(HttpAuthenticationMechanism.class); + private static final DotName BASIC_AUTH_MECH_NAME = DotName.createSimple(BasicAuthenticationMechanism.class); @Record(ExecutionTime.STATIC_INIT) @@ -173,20 +196,77 @@ 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(BasicAuthentication.class), + BasicAuthentication.AUTH_MECHANISM_SCHEME), + new HttpAuthMechanismAnnotationBuildItem(DotName.createSimple(FormAuthentication.class), + FormAuthentication.AUTH_MECHANISM_SCHEME), + new HttpAuthMechanismAnnotationBuildItem(DotName.createSimple(MTLSAuthentication.class), + MTLSAuthentication.AUTH_MECHANISM_SCHEME)); + } + + @BuildStep + @Record(ExecutionTime.STATIC_INIT) + void registerAuthMechanismSelectionInterceptor(Capabilities capabilities, HttpBuildTimeConfig buildTimeConfig, + BuildProducer bindingProducer, HttpSecurityRecorder recorder, + BuildProducer additionalSecuredMethodsProducer, + List additionalHttpAuthMechAnnotations, + CombinedIndexBuildItem combinedIndexBuildItem) { + // methods annotated with @HttpAuthenticationMechanism that we should additionally secure; + // when there is no other RBAC annotation applied + // then by default @HttpAuthenticationMechanism("any-value") == @Authenticated + Set methodsWithoutRbacAnnotations = new HashSet<>(); + + DotName[] mechNames = Stream + .concat(Stream.of(AUTH_MECHANISM_NAME), additionalHttpAuthMechAnnotations.stream().map(s -> s.annotationName)) + .flatMap(mechName -> { + var instances = combinedIndexBuildItem.getIndex().getAnnotations(mechName); + if (!instances.isEmpty()) { + // e.g. collect @Basic without @RolesAllowed, @PermissionsAllowed, .. + methodsWithoutRbacAnnotations + .addAll(collectMethodsWithoutRbacAnnotation(collectAnnotatedMethods(instances))); + methodsWithoutRbacAnnotations + .addAll(collectClassMethodsWithoutRbacAnnotation(collectAnnotatedClasses(instances))); + return Stream.of(mechName); + } else { + return Stream.empty(); + } + }).toArray(DotName[]::new); + + if (mechNames.length > 0) { + validateAuthMechanismAnnotationUsage(capabilities, buildTimeConfig, mechNames); + + // register method interceptor that will be run before security checks + Map knownBindingValues = additionalHttpAuthMechAnnotations.stream() + .collect(Collectors.toMap(item -> item.annotationName.toString(), item -> item.authMechanismScheme)); + bindingProducer.produce(new EagerSecurityInterceptorBindingBuildItem( + recorder.authMechanismSelectionInterceptorCreator(), knownBindingValues, mechNames)); + recorder.selectAuthMechanismViaAnnotation(); + + // make all @HttpAuthenticationMechanism annotation targets authenticated by default + if (!methodsWithoutRbacAnnotations.isEmpty()) { + // @RolesAllowed("**") == @Authenticated + additionalSecuredMethodsProducer.produce( + new AdditionalSecuredMethodsBuildItem(methodsWithoutRbacAnnotations, Optional.of(List.of("**")))); + } + } + } + + @BuildStep + void collectInterceptedMethods(CombinedIndexBuildItem indexBuildItem, + List interceptorBindings, + BuildProducer methodsProducer) { + if (!interceptorBindings.isEmpty()) { + var index = indexBuildItem.getIndex(); + Map> cache = new HashMap<>(); + Map>> result = new HashMap<>(); + addInterceptedEndpoints(interceptorBindings, index, AnnotationTarget.Kind.METHOD, result, cache); + addInterceptedEndpoints(interceptorBindings, index, AnnotationTarget.Kind.CLASS, result, cache); + if (!result.isEmpty()) { + result.forEach((annotationBinding, bindingValueToInterceptedMethods) -> methodsProducer.produce( + new EagerSecurityInterceptorMethodsBuildItem(bindingValueToInterceptedMethods, annotationBinding))); + } } } @@ -194,22 +274,182 @@ void collectEagerSecurityInterceptors(List producer, - Optional interceptors) { - if (interceptors.isPresent()) { + List interceptorBindings, + List interceptorMethods) { + if (!interceptorMethods.isEmpty()) { + final var bindingNameToInterceptorCreator = interceptorBindings + .stream() + .flatMap(binding -> Arrays.stream(binding.getAnnotationBindings()) + .map(name -> Map.entry(name, binding.getInterceptorCreator()))) + .collect(toMap(Map.Entry::getKey, Map.Entry::getValue)); + + 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 = mi.parameterTypes().stream().map(t -> t.name().toString()) + .toArray(String[]::new); + String className = mi.declaringClass().name().toString(); + String methodName = mi.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 void validateAuthMechanismAnnotationUsage(Capabilities capabilities, HttpBuildTimeConfig buildTimeConfig, + DotName[] annotationNames) { + if (buildTimeConfig.auth.proactive + || (!capabilities.isPresent(Capability.RESTEASY_REACTIVE) && !capabilities.isPresent(Capability.RESTEASY))) { + throw new ConfigurationException("Annotations '" + Arrays.toString(annotationNames) + "' 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.isSynthetic()) { + return false; + } + if (!Modifier.isPublic(info.flags())) { + return false; + } + if (info.isConstructor()) { + return false; + } + // instance methods only + return !Modifier.isStatic(info.flags()); + } + + private static void addInterceptedEndpoints(List interceptorBindings, + IndexView index, AnnotationTarget.Kind appliesTo, Map>> result, + Map> cache) { + for (EagerSecurityInterceptorBindingBuildItem interceptorBinding : interceptorBindings) { + for (DotName annotationBinding : interceptorBinding.getAnnotationBindings()) { + Map> bindingValueToInterceptedMethods = new HashMap<>(); + for (AnnotationInstance annotation : index.getAnnotations(annotationBinding)) { + if (annotation.target().kind() != appliesTo) { + continue; + } + if (annotation.target().kind() == AnnotationTarget.Kind.CLASS) { + for (MethodInfo method : annotation.target().asClass().methods()) { + if (hasProperEndpointModifiers(method)) { + // avoid situation when resource method is annotated with @Basic, class is annotated + // with @Bearer, and we apply the @Bearer annotation + boolean interceptorBindingNotAppliedOnMethodLevel = !cache.containsKey(method) + || !cache.get(method).contains(interceptorBinding); + + if (interceptorBindingNotAppliedOnMethodLevel) { + addInterceptedEndpoint(method, annotation, annotationBinding, + bindingValueToInterceptedMethods, interceptorBinding); + } + } + } + } else { + MethodInfo mi = annotation.target().asMethod(); + + // endpoint can only be annotated with one of @Basic, @Form, ... + // however combining @CodeFlow and @Tenant is supported + var appliedBindings = cache.computeIfAbsent(mi, a -> new ArrayList<>()); + if (appliedBindings.contains(interceptorBinding)) { + throw new RuntimeException( + "Only one of the '%s' annotations can be applied on the '%s' method".formatted( + interceptorBinding.getAnnotationBindings(), mi.declaringClass().name() + "#" + mi)); + } else { + appliedBindings.add(interceptorBinding); + } + + addInterceptedEndpoint(mi, annotation, annotationBinding, bindingValueToInterceptedMethods, + interceptorBinding); + } + } + if (!bindingValueToInterceptedMethods.isEmpty()) { + result.compute(annotationBinding, (key, existingMap) -> { + if (existingMap == null) { + return bindingValueToInterceptedMethods; + } else { + bindingValueToInterceptedMethods.forEach((annotationValue, methods) -> existingMap + .computeIfAbsent(annotationValue, a -> new ArrayList<>()).addAll(methods)); + return existingMap; + } + }); + } + } + } + } + + private static void addInterceptedEndpoint(MethodInfo classEndpoint, AnnotationInstance annotationInstance, + DotName annotation, Map> bindingValueToInterceptedMethods, + EagerSecurityInterceptorBindingBuildItem interceptorBinding) { + bindingValueToInterceptedMethods + .computeIfAbsent(interceptorBinding.getBindingValue(annotationInstance, annotation, classEndpoint), + s -> new ArrayList<>()) + .add(classEndpoint); + } + static class IsApplicationBasicAuthRequired implements BooleanSupplier { private final boolean required; diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/PolicyMappingConfig.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/PolicyMappingConfig.java index cb2656b15b9bda..a57753af3f8b74 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/PolicyMappingConfig.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/PolicyMappingConfig.java @@ -88,6 +88,16 @@ public enum AppliesTo { * the matching Jakarta REST endpoint. This option must be set if the following REST endpoint annotations are used: *

    *
  • `io.quarkus.oidc.Tenant` annotation which selects an OIDC authentication mechanism with a tenant identifier
  • + *
  • `io.quarkus.vertx.http.runtime.security.annotation.BasicAuthentication` which selects the Basic authentication + * mechanism
  • + *
  • `io.quarkus.vertx.http.runtime.security.annotation.FormAuthentication` which selects the Form-based + * authentication mechanism
  • + *
  • `io.quarkus.vertx.http.runtime.security.annotation.MTLSAuthentication` which selects the mTLS authentication + * mechanism
  • + *
  • `io.quarkus.security.webauthn.WebAuthn` which selects the WebAuth authentication mechanism
  • + *
  • `io.quarkus.oidc.BearerTokenAuthentication` which selects the OpenID Connect Bearer token authentication + * mechanism
  • + *
  • `io.quarkus.oidc.AuthorizationCodeFlow` which selects the OpenID Connect Code authentication mechanism
  • *
*/ JAXRS 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 d624696157fbe7..84eb2f49368a4f 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 @@ -2,6 +2,7 @@ import static io.quarkus.security.spi.runtime.SecurityEventHelper.AUTHENTICATION_FAILURE; import static io.quarkus.security.spi.runtime.SecurityEventHelper.AUTHENTICATION_SUCCESS; +import static java.lang.Boolean.TRUE; import java.util.ArrayList; import java.util.Collections; @@ -22,6 +23,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; @@ -40,7 +42,15 @@ @Singleton 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 boolean selectAuthMechanismWithAnnotation = false; private final IdentityProviderManager identityProviderManager; private final HttpAuthenticationMechanism[] mechanisms; private final SecurityEventHelper securityEventHelper; @@ -125,32 +135,37 @@ public IdentityProviderManager getIdentityProviderManager() { * If no credentials are present it will resolve to null. */ public Uni attemptAuthentication(RoutingContext routingContext) { - AbstractPathMatchingHttpSecurityPolicy pathMatchingPolicy = routingContext - .get(AbstractPathMatchingHttpSecurityPolicy.class.getName()); + // we need to keep track of authN attempts so that we know authN only happens after + // the HTTP request has been matched with the annotated method + if (selectAuthMechanismWithAnnotation) { + rememberAuthAttempted(routingContext); + } - String pathSpecificMechanism = pathMatchingPolicy != null - ? pathMatchingPolicy.getAuthMechanismName(routingContext) - : null; - Uni matchingMechUni = findBestCandidateMechanism(routingContext, pathSpecificMechanism); + // determine whether user selected path specific mechanism via HTTP Security policy or annotation + final String pathSpecificMechanism; + if (selectAuthMechanismWithAnnotation && isAuthMechanismSelected(routingContext)) { + pathSpecificMechanism = routingContext.get(AUTH_MECHANISM); + } else { + AbstractPathMatchingHttpSecurityPolicy pathMatchingPolicy = routingContext + .get(AbstractPathMatchingHttpSecurityPolicy.class.getName()); + pathSpecificMechanism = pathMatchingPolicy != null ? pathMatchingPolicy.getAuthMechanismName(routingContext) : null; + } + + // authenticate Uni result; - if (matchingMechUni == null) { - result = createSecurityIdentity(routingContext); + if (pathSpecificMechanism == null) { + result = createSecurityIdentity(routingContext, 0); } else { - result = matchingMechUni.onItem() + result = findBestCandidateMechanism(routingContext, pathSpecificMechanism, 0).onItem().ifNotNull() .transformToUni(new Function>() { - @Override public Uni apply(HttpAuthenticationMechanism mech) { - if (mech != null) { - return mech.authenticate(routingContext, identityProviderManager); - } else if (pathSpecificMechanism != null) { - return Uni.createFrom().optional(Optional.empty()); - } - return createSecurityIdentity(routingContext); + return mech.authenticate(routingContext, identityProviderManager); } - }); } + + // fire security events if required if (securityEventHelper.fireEventOnFailure()) { result = result.onFailure().invoke(new Consumer() { @Override @@ -169,24 +184,27 @@ public void accept(SecurityIdentity securityIdentity) { } }); } + return result; } - private Uni createSecurityIdentity(RoutingContext routingContext) { - Uni result = mechanisms[0].authenticate(routingContext, identityProviderManager); - for (int i = 1; i < mechanisms.length; ++i) { - HttpAuthenticationMechanism mech = mechanisms[i]; - result = result.onItem().transformToUni(new Function>() { - @Override - public Uni apply(SecurityIdentity data) { - if (data != null) { - return Uni.createFrom().item(data); - } - return mech.authenticate(routingContext, identityProviderManager); - } - }); + private Uni createSecurityIdentity(RoutingContext routingContext, int i) { + if (i == mechanisms.length) { + return Uni.createFrom().nullItem(); } - return result; + return mechanisms[i].authenticate(routingContext, identityProviderManager) + .onItem().transformToUni(new Function>() { + @Override + public Uni apply(SecurityIdentity identity) { + if (identity != null) { + if (selectAuthMechanismWithAnnotation && !isAuthMechanismSelected(routingContext)) { + return rememberAuthMechScheme(mechanisms[i], routingContext).replaceWith(identity); + } + return Uni.createFrom().item(identity); + } + return createSecurityIdentity(routingContext, i + 1); + } + }); } /** @@ -263,26 +281,23 @@ public Uni apply(ChallengeData data) { } private Uni findBestCandidateMechanism(RoutingContext routingContext, - String pathSpecificMechanism) { - Uni result = null; - - if (pathSpecificMechanism != null) { - result = getPathSpecificMechanism(0, routingContext, pathSpecificMechanism); - for (int i = 1; i < mechanisms.length; ++i) { - int mechIndex = i; - result = result.onItem().transformToUni( - new Function>() { - @Override - public Uni apply(HttpAuthenticationMechanism mech) { - if (mech != null) { - return Uni.createFrom().item(mech); - } - return getPathSpecificMechanism(mechIndex, routingContext, pathSpecificMechanism); - } - }); - } + String pathSpecificMechanism, int i) { + if (i == mechanisms.length) { + return Uni.createFrom().nullItem(); } - return result; + return getPathSpecificMechanism(i, routingContext, pathSpecificMechanism).onItem().transformToUni( + new Function>() { + @Override + public Uni apply(HttpAuthenticationMechanism mech) { + if (mech != null) { + if (selectAuthMechanismWithAnnotation && !isAuthMechanismSelected(routingContext)) { + return rememberAuthMechScheme(mech, routingContext).replaceWith(mech); + } + return Uni.createFrom().item(mech); + } + return findBestCandidateMechanism(routingContext, pathSpecificMechanism, i + 1); + } + }); } private Uni getPathSpecificMechanism(int index, RoutingContext routingContext, @@ -293,6 +308,7 @@ private Uni getPathSpecificMechanism(int index, Rou public HttpAuthenticationMechanism apply(HttpCredentialTransport t) { if (t != null && t.getAuthenticationScheme().equalsIgnoreCase(pathSpecificMechanism)) { routingContext.put(HttpAuthenticationMechanism.class.getName(), mechanisms[index]); + routingContext.put(AUTH_MECHANISM, t.getAuthenticationScheme()); return mechanisms[index]; } return null; @@ -300,6 +316,25 @@ public HttpAuthenticationMechanism apply(HttpCredentialTransport t) { }); } + static void selectAuthMechanismWithAnnotation() { + selectAuthMechanismWithAnnotation = true; + } + + static void selectAuthMechanism(RoutingContext routingContext, String authMechanism) { + if (requestAlreadyAuthenticated(routingContext, authMechanism)) { + throw new AuthenticationFailedException(""" + The '%1$s' authentication mechanism is required to authenticate the request but it was already + authenticated with the '%2$s' authentication mechanism. It can happen if the '%1$s' is selected with + an annotation but '%2$s' is activated by the HTTP security policy which is enforced before + the JAX-RS chain is run. In such cases, please set the + 'quarkus.http.auth.permission."permissions".applies-to=JAXRS' to all HTTP security policies + which secure the same REST endpoints as the ones secured by the '%1$s' authentication mechanism + selected with the annotation. + """.formatted(authMechanism, routingContext.get(AUTH_MECHANISM))); + } + routingContext.put(AUTH_MECHANISM, authMechanism); + } + private static Uni getCredentialTransport(HttpAuthenticationMechanism mechanism, RoutingContext routingContext) { try { @@ -309,6 +344,39 @@ private static Uni getCredentialTransport(HttpAuthentic } } + private static void rememberAuthAttempted(RoutingContext routingContext) { + routingContext.put(ATTEMPT_AUTH_INVOKED, TRUE); + } + + private static boolean isAuthMechanismSelected(RoutingContext routingContext) { + return routingContext.get(AUTH_MECHANISM) != null; + } + + private static boolean requestAlreadyAuthenticated(RoutingContext event, String newAuthMechanism) { + return event.get(ATTEMPT_AUTH_INVOKED) == TRUE && authenticatedWithDifferentAuthMechanism(newAuthMechanism, event); + } + + private static boolean authenticatedWithDifferentAuthMechanism(String newAuthMechanism, RoutingContext event) { + return !newAuthMechanism.equalsIgnoreCase(event.get(AUTH_MECHANISM)); + } + + /** + * Remember authentication mechanism used for authentication so that we know what mechanism has been used + * in case that someone tries to change the mechanism after the authentication. This way, we can be permissive + * when the selected mechanism is same as the one already used. + */ + private static Uni rememberAuthMechScheme(HttpAuthenticationMechanism mech, RoutingContext event) { + return getCredentialTransport(mech, event) + .onItem().ifNotNull().invoke(new Consumer() { + @Override + public void accept(HttpCredentialTransport t) { + if (t.getAuthenticationScheme() != null) { + event.put(AUTH_MECHANISM, t.getAuthenticationScheme()); + } + } + }); + } + static class NoAuthenticationMechanism implements HttpAuthenticationMechanism { @Override 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 ae7e5ca6c23600..3bd61f39855568 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 @@ -420,6 +420,47 @@ public void setMtlsCertificateRoleProperties(HttpConfiguration config) { } } + 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 @HttpAuthenticationMechanism("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) { + HttpAuthenticator.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.selectAuthMechanismWithAnnotation(); + } + private static Set parseRoles(String value) { Set roles = new HashSet<>(); for (String s : value.split(",")) { diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/annotation/BasicAuthentication.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/annotation/BasicAuthentication.java new file mode 100644 index 00000000000000..f5468448cc7d66 --- /dev/null +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/annotation/BasicAuthentication.java @@ -0,0 +1,22 @@ +package io.quarkus.vertx.http.runtime.security.annotation; + +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.BasicAuthenticationMechanism; + +/** + * Selects {@link BasicAuthenticationMechanism}. + * + * @see HttpAuthenticationMechanism for more information + */ +@HttpAuthenticationMechanism("basic") +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE, ElementType.METHOD }) +public @interface BasicAuthentication { + + String AUTH_MECHANISM_SCHEME = "basic"; + +} diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/annotation/FormAuthentication.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/annotation/FormAuthentication.java new file mode 100644 index 00000000000000..4ec7278c10b0e6 --- /dev/null +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/annotation/FormAuthentication.java @@ -0,0 +1,22 @@ +package io.quarkus.vertx.http.runtime.security.annotation; + +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.FormAuthenticationMechanism; + +/** + * Selects {@link FormAuthenticationMechanism}. + * + * @see HttpAuthenticationMechanism for more information + */ +@HttpAuthenticationMechanism("form") +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE, ElementType.METHOD }) +public @interface FormAuthentication { + + String AUTH_MECHANISM_SCHEME = "form"; + +} diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/annotation/HttpAuthenticationMechanism.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/annotation/HttpAuthenticationMechanism.java new file mode 100644 index 00000000000000..6146b7b5339a3e --- /dev/null +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/annotation/HttpAuthenticationMechanism.java @@ -0,0 +1,29 @@ +package io.quarkus.vertx.http.runtime.security.annotation; + +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.quarkus.vertx.http.runtime.security.HttpCredentialTransport; +import io.vertx.ext.web.RoutingContext; + +/** + * Provides a way to select custom {@link io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism} + * used for a REST endpoint authentication. Mechanisms provided by + * 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 HttpAuthenticationMechanism { + /** + * {@link io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism} scheme as returned by + * {@link HttpCredentialTransport#getAuthenticationScheme()}. + * Custom mechanisms can set this name inside + * {@link io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism#getCredentialTransport(RoutingContext)}. + */ + String value(); +} diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/annotation/MTLSAuthentication.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/annotation/MTLSAuthentication.java new file mode 100644 index 00000000000000..de1727f854627c --- /dev/null +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/annotation/MTLSAuthentication.java @@ -0,0 +1,22 @@ +package io.quarkus.vertx.http.runtime.security.annotation; + +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.MtlsAuthenticationMechanism; + +/** + * Selects {@link MtlsAuthenticationMechanism}. + * + * @see HttpAuthenticationMechanism for more information + */ +@HttpAuthenticationMechanism("X509") +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE, ElementType.METHOD }) +public @interface MTLSAuthentication { + + String AUTH_MECHANISM_SCHEME = "X509"; + +} 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..1cee7d102405f9 --- /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.AuthorizationCodeFlow; +import io.quarkus.security.PermissionsAllowed; +import io.quarkus.vertx.http.runtime.security.annotation.BasicAuthentication; + +@Path("multiple-auth-mech") +public class MultipleAuthMechanismResource { + + @BasicAuthentication + @PermissionsAllowed("permission1") + @GET + @Path("basic") + public String basicAuthMech() { + return "basicAuthMech"; + } + + @AuthorizationCodeFlow + @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 0096a38a3401fb..6b695facdaa538 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 @@ -25,6 +25,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; @@ -1486,6 +1487,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..3071dc9c390e3e 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.AuthorizationCodeFlow; +import io.quarkus.oidc.BearerTokenAuthentication; 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); } + @AuthorizationCodeFlow + @GET + @Path("code-flow-auth-mech-annotation") + @RolesAllowed("user") + public String codeFlowAuthMechSelectedExplicitly() { + return "code-flow-auth-mech-annotation"; + } + + @BearerTokenAuthentication + @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 e1fb18f39198db..f0fca577049aa9 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; @@ -801,6 +803,50 @@ private void assertStaticTenantFailure(String clientId, String subPath) { RestAssured.given().auth().oauth2(accessToken).when().get("/api/tenant-paths/" + subPath).then().statusCode(401); } + @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); }