diff --git a/plume-web-jersey/README.md b/plume-web-jersey/README.md index 1292244..dd711a4 100644 --- a/plume-web-jersey/README.md +++ b/plume-web-jersey/README.md @@ -137,3 +137,103 @@ public void waitAsync(@Suspended final AsyncResponse asyncResponse) { } ``` +Requests authorization +---------------------- +Multiple authorization provider are implemented. +For all provider, the HTTP header `Authorization` is used to fetch the authorization value. + +### Basic authorization +This feature is provided by the `BasicAuthenticator` class. +Sample usage: +```java +@Path("/example") +@Tag(name = "example", description = "Manage exemple web-services") +@Consumes(MediaType.APPLICATION_JSON) +@Produces(MediaType.APPLICATION_JSON) +// Since we are performing authorization in the resource, we can mark this API as public +@PublicApi +@Singleton +public class ExampleWs { + private final BasicAuthenticator basicAuthenticator; + + @Inject + public ExampleWs() { + this.basicAuthenticator = BasicAuthenticator.fromSingleCredentials( + "my-username", + "my-password", + // Message displayed to the user trying to access the API with a browser + "My protected API" + ); + } + + @GET + @Path("/test") + @Operation(description = "Example web-service") + public Test test(@Context ContainerRequestContext requestContext) { + basicAuthenticator.requireAuthentication(requestContext); + + return new Test("Hello world"); + } +} +``` + +One sample valid request: `curl -u 'my-username:my-password' 'http://localhost:8080/api/example/test'` + +### API Key authorization +This feature is provided by the `BearerAuthenticator` class. +Sample usage: +```java +@Path("/example") +@Tag(name = "example", description = "Manage exemple web-services") +@Consumes(MediaType.APPLICATION_JSON) +@Produces(MediaType.APPLICATION_JSON) +// Since we are performing authorization in the resource, we can mark this API as public +@PublicApi +@Singleton +public class ExampleWs { + private final BearerAuthenticator bearerAuthenticator; + + @Inject + public ExampleWs() { + this.bearerAuthenticator = new BearerAuthenticator("my-bearer-token"); + } + + @GET + @Path("/test") + @Operation(description = "Example web-service") + public Test test(@Context ContainerRequestContext requestContext) { + bearerAuthenticator.verifyAuthentication(requestContext); + + return new Test("Hello world"); + } +} +``` + +One sample valid request: `curl -H 'Authorization: Bearer my-bearer-token' 'http://localhost:8080/api/example/test'` + +### Resource authorization based on annotation +This feature is provided `AuthorizationSecurityFeature` and works with any authentication system. +For example using bearer auth: +- In the `JerseyConfigProvider` file, declare the feature with the annotation used for resource identification: `config.register(new BearerAuthenticator("my-bearer-token").toAuthorizationFeature(BearerRestricted.class));` +- In the `JerseyConfigProvider` file, register if needed the annotation used in the `RequireExplicitAccessControlFeature`: `config.register(RequireExplicitAccessControlFeature.accessControlAnnotations(PublicApi.class, BearerRestricted.class));` +- Use the annotation in a resource definition: +```java +@Path("/example") +@Tag(name = "example", description = "Manage exemple web-services") +@Consumes(MediaType.APPLICATION_JSON) +@Produces(MediaType.APPLICATION_JSON) +// The new annotation that will ensure the authorization process before granting access +@BearerRestricted +@Singleton +public class ExampleWs { + @GET + @Path("/test") + @Operation(description = "Example web-service") + public Test test() { + return new Test("Hello world"); + } +} +``` + +### Permissions based authorization +TODO to details diff --git a/plume-web-jersey/pom.xml b/plume-web-jersey/pom.xml index a702eee..4dc1ebb 100644 --- a/plume-web-jersey/pom.xml +++ b/plume-web-jersey/pom.xml @@ -121,6 +121,17 @@ assertj-core test + + ch.qos.logback + logback-classic + test + + + org.mockito + mockito-core + 5.14.2 + test + org.glassfish.jersey.test-framework jersey-test-framework-core diff --git a/plume-web-jersey/src/main/java/com/coreoz/plume/jersey/security/AuthorizationSecurityFeature.java b/plume-web-jersey/src/main/java/com/coreoz/plume/jersey/security/AuthorizationSecurityFeature.java new file mode 100644 index 0000000..3f596aa --- /dev/null +++ b/plume-web-jersey/src/main/java/com/coreoz/plume/jersey/security/AuthorizationSecurityFeature.java @@ -0,0 +1,69 @@ +package com.coreoz.plume.jersey.security; + +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.container.DynamicFeature; +import jakarta.ws.rs.container.ResourceInfo; +import jakarta.ws.rs.core.FeatureContext; +import lombok.extern.slf4j.Slf4j; + +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedElement; + +/** + * Provide a general way of implementing a Jersey request authentication filter + * that will be active on a method or a class depending on the presence of an annotation.
+ *
+ * The priority will be:
+ * 1. If the annotation is present on the resource method, then this annotation will be used when calling the {@link AuthorizationVerifier}
+ * 2. Else if the annotation is present on the resource class, then this annotation will be used when calling the {@link AuthorizationVerifier}
+ * 3. Else the authentication filter will not be active for the whole resource + * @param The annotation type that will be looked to on the resource to enable the authentication filter + * @see com.coreoz.plume.jersey.security.basic.BasicAuthenticator + * @see com.coreoz.plume.jersey.security.bearer.BearerAuthenticator + * @see com.coreoz.plume.jersey.security.permission.PermissionFeature + */ +@Slf4j +public class AuthorizationSecurityFeature implements DynamicFeature { + private final Class annotation; + private final AuthorizationVerifier authorizationVerifier; + + /** + * @param annotation The annotation type that will hold the authentication + * @param authorizationVerifier The function that will verify that the request is authorized, else it should throw {@link jakarta.ws.rs.ForbiddenException} or {@link jakarta.ws.rs.ClientErrorException} + */ + public AuthorizationSecurityFeature(Class annotation, AuthorizationVerifier authorizationVerifier) { + this.annotation = annotation; + this.authorizationVerifier = authorizationVerifier; + } + + @Override + public void configure(ResourceInfo methodResourceInfo, FeatureContext methodResourceContext) { + A methodAnnotation = getAnnotation(methodResourceInfo.getResourceMethod()); + if (methodAnnotation != null) { + methodResourceContext.register(new AuthorizationFilter(methodAnnotation)); + } else { + A classAnnotation = getAnnotation(methodResourceInfo.getResourceClass()); + if (classAnnotation != null) { + methodResourceContext.register(new AuthorizationFilter(classAnnotation)); + } + } + } + + private A getAnnotation(AnnotatedElement annotatedElement) { + return annotatedElement.getAnnotation(annotation); + } + + private class AuthorizationFilter implements ContainerRequestFilter { + private final A authorizationAnnotation; + + public AuthorizationFilter(A authorizationAnnotation) { + this.authorizationAnnotation = authorizationAnnotation; + } + + @Override + public void filter(ContainerRequestContext requestContext) { + authorizationVerifier.verifyAuthentication(authorizationAnnotation, requestContext); + } + } +} diff --git a/plume-web-jersey/src/main/java/com/coreoz/plume/jersey/security/AuthorizationVerifier.java b/plume-web-jersey/src/main/java/com/coreoz/plume/jersey/security/AuthorizationVerifier.java new file mode 100644 index 0000000..98e1f1b --- /dev/null +++ b/plume-web-jersey/src/main/java/com/coreoz/plume/jersey/security/AuthorizationVerifier.java @@ -0,0 +1,27 @@ +package com.coreoz.plume.jersey.security; + +import jakarta.ws.rs.ClientErrorException; +import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.container.ContainerRequestContext; + +import javax.annotation.Nonnull; +import java.lang.annotation.Annotation; + +/** + * The function that will verify that an incoming request is authorized or not
+ *
+ * This function should be used within the {@link AuthorizationSecurityFeature}. + * @param
The annotation type used to identify that the verifier should be applied on a resource method/class + */ +@FunctionalInterface +public interface AuthorizationVerifier { + /** + * Verify that an incoming request is authorized + * + * @param authorizationAnnotation The annotation type used to identify that the verifier should be applied on a resource method/class + * @param requestContext The request context that should be used to verify the authorization + * @throws ForbiddenException If the user cannot access the resource + * @throws ClientErrorException If there is another error about the request authorization + */ + void verifyAuthentication(@Nonnull A authorizationAnnotation, @Nonnull ContainerRequestContext requestContext) throws ForbiddenException, ClientErrorException; +} diff --git a/plume-web-jersey/src/main/java/com/coreoz/plume/jersey/security/basic/BasicAuthenticator.java b/plume-web-jersey/src/main/java/com/coreoz/plume/jersey/security/basic/BasicAuthenticator.java index ba6b936..dbdee6f 100644 --- a/plume-web-jersey/src/main/java/com/coreoz/plume/jersey/security/basic/BasicAuthenticator.java +++ b/plume-web-jersey/src/main/java/com/coreoz/plume/jersey/security/basic/BasicAuthenticator.java @@ -1,15 +1,17 @@ package com.coreoz.plume.jersey.security.basic; -import lombok.extern.slf4j.Slf4j; - +import com.coreoz.plume.jersey.security.AuthorizationSecurityFeature; import jakarta.ws.rs.ClientErrorException; import jakarta.ws.rs.ForbiddenException; import jakarta.ws.rs.container.ContainerRequestContext; import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response.Status; +import lombok.extern.slf4j.Slf4j; +import java.lang.annotation.Annotation; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.util.Base64; import java.util.function.Function; @@ -42,15 +44,14 @@ public BasicAuthenticator(Function authenticator, String realm) /** * Create a new {@link BasicAuthenticator} for a unique credentials. - * This should be used to protect a non strategic resource since users - * will all share the same user name and password. + * This should be used to protect a non-strategic resource since users + * will all share the same username and password. */ public static BasicAuthenticator fromSingleCredentials(String singleUsername, String password, String realm) { return new BasicAuthenticator<>( - credentials -> - singleUsername.equals(credentials.getUsername()) - && password.equals(credentials.getPassword()) ? + credentials -> MessageDigest.isEqual(singleUsername.getBytes(), credentials.getUsername().getBytes()) + && MessageDigest.isEqual(password.getBytes(),credentials.getPassword().getBytes()) ? "Basic user" : null, realm @@ -59,6 +60,20 @@ public static BasicAuthenticator fromSingleCredentials(String singleUser // API + /** + * Provide a {@link AuthorizationSecurityFeature} from the bearer basic that can be used in Jersey + * to provide authentication on annotated resources. + * @param basicAnnotation The annotation that will be used to identify resources that must be authorized. For example {@link BasicRestricted} can be used if it is not already used in the project for another authorization system + * @return The corresponding {@link AuthorizationSecurityFeature} + * @param The annotation type used to identify required basic authenticated resources + */ + public AuthorizationSecurityFeature toAuthorizationFeature(Class basicAnnotation) { + return new AuthorizationSecurityFeature<>( + basicAnnotation, + (authorizationAnnotation, requestContext) -> requireAuthentication(requestContext) + ); + } + /** * Check that users accessing the resource are well authenticated. * A {@link ClientErrorException} will be raised if a user is not authenticated. diff --git a/plume-web-jersey/src/main/java/com/coreoz/plume/jersey/security/basic/BasicRestricted.java b/plume-web-jersey/src/main/java/com/coreoz/plume/jersey/security/basic/BasicRestricted.java new file mode 100644 index 0000000..3526b23 --- /dev/null +++ b/plume-web-jersey/src/main/java/com/coreoz/plume/jersey/security/basic/BasicRestricted.java @@ -0,0 +1,19 @@ +package com.coreoz.plume.jersey.security.basic; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * An annotation that can be used to set up an {@link com.coreoz.plume.jersey.security.AuthorizationSecurityFeature} + * with a {@link BasicAuthenticator} + */ +@Documented +@Retention(RUNTIME) +@Target({TYPE, METHOD}) +public @interface BasicRestricted { +} diff --git a/plume-web-jersey/src/main/java/com/coreoz/plume/jersey/security/bearer/BearerAuthenticator.java b/plume-web-jersey/src/main/java/com/coreoz/plume/jersey/security/bearer/BearerAuthenticator.java new file mode 100644 index 0000000..049a9af --- /dev/null +++ b/plume-web-jersey/src/main/java/com/coreoz/plume/jersey/security/bearer/BearerAuthenticator.java @@ -0,0 +1,62 @@ +package com.coreoz.plume.jersey.security.bearer; + +import com.coreoz.plume.jersey.security.AuthorizationSecurityFeature; +import com.google.common.net.HttpHeaders; +import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.container.ContainerRequestContext; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; + +import java.lang.annotation.Annotation; +import java.security.MessageDigest; + +/** + * Provide a generic resource authenticator for API-key based resource authorization + */ +@Slf4j +public class BearerAuthenticator { + private static final String BEARER_PREFIX = "Bearer "; + + private final String authenticationSecretHeader; + + public BearerAuthenticator(String bearerToken) { + this.authenticationSecretHeader = BEARER_PREFIX + bearerToken; + } + + /** + * Provide a {@link AuthorizationSecurityFeature} from the bearer authenticator that can be used in Jersey + * to provide authentication on annotated resources. + * @param bearerAnnotation The annotation that will be used to identify resources that must be authorized. For example {@link BearerRestricted} can be used if it is not already used in the project for another authorization system + * @return The corresponding {@link AuthorizationSecurityFeature} + * @param The annotation type used to identify required bearer authenticated resources + */ + public AuthorizationSecurityFeature toAuthorizationFeature(Class bearerAnnotation) { + return new AuthorizationSecurityFeature<>( + bearerAnnotation, + (authorizationAnnotation, requestContext) -> verifyAuthentication(requestContext) + ); + } + + public void verifyAuthentication(@NotNull ContainerRequestContext requestContext) { + String bearer = parseBearerHeader(requestContext.getHeaderString(HttpHeaders.AUTHORIZATION)); + + if (bearer == null || !MessageDigest.isEqual(authenticationSecretHeader.getBytes(), bearer.getBytes())) { + logger.warn("Invalid bearer header: {}", bearer); + throw new ForbiddenException(); + } + } + + public static String parseBearerHeader(String authorizationHeader) { + if(authorizationHeader == null) { + logger.debug("Missing Authorization header"); + return null; + } + + if(!authorizationHeader.startsWith(BEARER_PREFIX)) { + logger.debug("Bearer Authorization header must starts with '{}'", BEARER_PREFIX); + return null; + } + + return authorizationHeader; + } +} diff --git a/plume-web-jersey/src/main/java/com/coreoz/plume/jersey/security/bearer/BearerRestricted.java b/plume-web-jersey/src/main/java/com/coreoz/plume/jersey/security/bearer/BearerRestricted.java new file mode 100644 index 0000000..24f0d7b --- /dev/null +++ b/plume-web-jersey/src/main/java/com/coreoz/plume/jersey/security/bearer/BearerRestricted.java @@ -0,0 +1,19 @@ +package com.coreoz.plume.jersey.security.bearer; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * An annotation that can be used to set up an {@link com.coreoz.plume.jersey.security.AuthorizationSecurityFeature} + * with a {@link BearerAuthenticator} + */ +@Documented +@Retention(RUNTIME) +@Target({TYPE, METHOD}) +public @interface BearerRestricted { +} diff --git a/plume-web-jersey/src/main/java/com/coreoz/plume/jersey/security/permission/PermissionFeature.java b/plume-web-jersey/src/main/java/com/coreoz/plume/jersey/security/permission/PermissionFeature.java index b0118f0..83deced 100644 --- a/plume-web-jersey/src/main/java/com/coreoz/plume/jersey/security/permission/PermissionFeature.java +++ b/plume-web-jersey/src/main/java/com/coreoz/plume/jersey/security/permission/PermissionFeature.java @@ -1,22 +1,17 @@ package com.coreoz.plume.jersey.security.permission; -import java.io.IOException; -import java.lang.annotation.Annotation; -import java.lang.reflect.AnnotatedElement; -import java.util.Collection; -import java.util.function.Function; - +import com.coreoz.plume.jersey.security.AuthorizationSecurityFeature; +import com.coreoz.plume.jersey.security.AuthorizationVerifier; import jakarta.ws.rs.ForbiddenException; import jakarta.ws.rs.container.ContainerRequestContext; -import jakarta.ws.rs.container.ContainerRequestFilter; import jakarta.ws.rs.container.DynamicFeature; import jakarta.ws.rs.container.ResourceInfo; import jakarta.ws.rs.core.FeatureContext; - import lombok.extern.slf4j.Slf4j; -import org.glassfish.jersey.server.internal.LocalizationMessages; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; + +import java.lang.annotation.Annotation; +import java.util.Collection; +import java.util.function.Function; /** * Add a permission mechanism to restrict web-service access. @@ -25,17 +20,44 @@ */ @Slf4j public class PermissionFeature implements DynamicFeature { - private final PermissionRequestProvider requestPermissionProvider; - private final Class permissionAnnotationType; - private final Function permissionAnnotationExtractor; + private final AuthorizationSecurityFeature authorizationSecurityFeature; public PermissionFeature(PermissionRequestProvider requestPermissionProvider, Class permissionAnnotationType, Function permissionAnnotationExtractor) { - this.requestPermissionProvider = requestPermissionProvider; - this.permissionAnnotationType = permissionAnnotationType; - this.permissionAnnotationExtractor = permissionAnnotationExtractor; + this.authorizationSecurityFeature = new AuthorizationSecurityFeature( + permissionAnnotationType, + makeAuthorizationVerifier(requestPermissionProvider, permissionAnnotationExtractor) + ); } + private static AuthorizationVerifier makeAuthorizationVerifier( + PermissionRequestProvider requestPermissionProvider, + Function permissionAnnotationExtractor + ) { + return (authorizationAnnotation, requestContext) -> { + if(!authorize(requestPermissionProvider, requestContext, permissionAnnotationExtractor.apply(authorizationAnnotation))) { + throw new ForbiddenException(); + } + }; + } + + private static boolean authorize(PermissionRequestProvider requestPermissionProvider, ContainerRequestContext requestContext, String permissionRequiredToAccessResource) { + Collection userPermissions = requestPermissionProvider.correspondingPermissions(requestContext); + boolean isAuthorized = userPermissions.contains(permissionRequiredToAccessResource); + + if(!isAuthorized) { + logger.warn( + "Unauthorized access to {} by {}, required permission '{}' not found among {}", + requestContext.getUriInfo().getAbsolutePath(), + requestPermissionProvider.userInformation(requestContext), + permissionRequiredToAccessResource, + userPermissions + ); + } + + return isAuthorized; + } + public static PermissionFeature restrictTo(PermissionRequestProvider requestPermissionProvider) { return new PermissionFeature<>( requestPermissionProvider, @@ -45,64 +67,7 @@ public static PermissionFeature restrictTo(PermissionRequestProvider } @Override - public void configure(ResourceInfo methodResourceInfo, FeatureContext methodResourcecontext) { - // if the method is annotated with RestrictTo, - // then this annotation value will be used instead of the class annotation - if(!addPermissionFilter(methodResourceInfo.getResourceMethod(), methodResourcecontext)) { - // if the method isn't annotated with RestrictTo, - // then the class annotation will be used if present - addPermissionFilter(methodResourceInfo.getResourceClass(), methodResourcecontext); - } - } - - /** - * Add a filter with the appropriate permission to the context if the annotatedElement is annotated with {@link #A}. - * @return true if the filter has been added to the context, else false - */ - private boolean addPermissionFilter(AnnotatedElement annotatedElement, FeatureContext methodResourcecontext) { - A permissionAnnotation = annotatedElement.getAnnotation(permissionAnnotationType); - if (permissionAnnotation != null) { - methodResourcecontext.register(new PermissionRequestFilter( - permissionAnnotationExtractor.apply(permissionAnnotation), - requestPermissionProvider - )); - return true; - } - return false; - } - - private static class PermissionRequestFilter implements ContainerRequestFilter { - private final String resourcePermission; - private final PermissionRequestProvider requestPermissionProvider; - - public PermissionRequestFilter(String resourcePermission, - PermissionRequestProvider requestPermissionProvider) { - this.resourcePermission = resourcePermission; - this.requestPermissionProvider = requestPermissionProvider; - } - - @Override - public void filter(ContainerRequestContext requestContext) throws IOException { - if(!authorize(requestContext, resourcePermission)) { - throw new ForbiddenException(LocalizationMessages.USER_NOT_AUTHORIZED()); - } - } - - private boolean authorize(ContainerRequestContext requestContext, String permissionRequiredToAccessResource) { - Collection userPermissions = requestPermissionProvider.correspondingPermissions(requestContext); - boolean isAuthorized = userPermissions.contains(permissionRequiredToAccessResource); - - if(!isAuthorized) { - logger.warn( - "Unauthorized access to {} by {}, required permission '{}' not found among {}", - requestContext.getUriInfo().getAbsolutePath(), - requestPermissionProvider.userInformation(requestContext), - permissionRequiredToAccessResource, - userPermissions - ); - } - - return isAuthorized; - } + public void configure(ResourceInfo methodResourceInfo, FeatureContext methodResourceContext) { + authorizationSecurityFeature.configure(methodResourceInfo, methodResourceContext); } } diff --git a/plume-web-jersey/src/test/java/com/coreoz/plume/jersey/security/AuthorizationSecurityFeatureTest.java b/plume-web-jersey/src/test/java/com/coreoz/plume/jersey/security/AuthorizationSecurityFeatureTest.java new file mode 100644 index 0000000..b4eca3c --- /dev/null +++ b/plume-web-jersey/src/test/java/com/coreoz/plume/jersey/security/AuthorizationSecurityFeatureTest.java @@ -0,0 +1,133 @@ +package com.coreoz.plume.jersey.security; + +import com.google.common.net.HttpHeaders; +import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.client.WebTarget; +import jakarta.ws.rs.core.Application; +import jakarta.ws.rs.core.Response; +import org.glassfish.jersey.server.ResourceConfig; +import org.glassfish.jersey.test.JerseyTest; +import org.junit.Test; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.concurrent.atomic.AtomicInteger; + +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.TYPE; +import static org.assertj.core.api.Assertions.assertThat; + +public class AuthorizationSecurityFeatureTest extends JerseyTest { + private final AtomicInteger verifierCounter = new AtomicInteger(0); + + @Path("/test-resource") + public static class TestResourceNotAnnotated { + @GET + public String testMethod() { + return "Success"; + } + } + + @Path("/test-resource-method") + public static class TestResourceMethod { + @GET + @MyTestAnnotation + public String testMethod() { + return "Success"; + } + } + + @MyTestAnnotation + @Path("/test-resource-class") + public static class TestResourceClass { + @GET + public String testMethod() { + return "Success"; + } + } + + // Define a custom annotation for testing purposes + @Retention(RetentionPolicy.RUNTIME) + @Target({TYPE, METHOD}) + public @interface MyTestAnnotation {} + + @Override + protected Application configure() { + AuthorizationVerifier mockVerifier = (authorizationAnnotation, requestContext) -> { + verifierCounter.incrementAndGet(); + if (requestContext.getHeaderString(HttpHeaders.AUTHORIZATION) == null) { + throw new ForbiddenException(); + } + }; + + // Register the feature and the test resource class + return new ResourceConfig() + .register(new AuthorizationSecurityFeature<>(MyTestAnnotation.class, mockVerifier)) + .register(TestResourceNotAnnotated.class) + .register(TestResourceMethod.class) + .register(TestResourceClass.class); + } + + @Test + public void shouldThrowForbiddenIfAuthorizationFails() { + // Act: Call the test resource and expect a forbidden status + int baseCount = verifierCounter.get(); + WebTarget target = target("/test-resource-method"); + Response response = target.request().get(); + + // Assert: The response should be 403 Forbidden + assertThat(response.getStatus()).isEqualTo(403); + + // Verify that the AuthorizationVerifier was called with the correct parameters + assertThat(verifierCounter.get() - baseCount).isOne(); + } + + @Test + public void shouldApplyFilterForMethodAnnotation() { + // Act: Call the test resource + int baseCount = verifierCounter.get(); + WebTarget target = target("/test-resource-method"); + Response response = target.request().header(HttpHeaders.AUTHORIZATION, "valid auth").get(); + + // Assert: The response should be successful + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.readEntity(String.class)).isEqualTo("Success"); + + // Verify that the AuthorizationVerifier was called with the correct annotation + assertThat(verifierCounter.get() - baseCount).isOne(); + } + + @Test + public void shouldApplyFilterForClassAnnotation() { + // Act: Call the test resource + int baseCount = verifierCounter.get(); + WebTarget target = target("/test-resource-class"); + Response response = target.request().header(HttpHeaders.AUTHORIZATION, "valid auth").get(); + + // Assert: The response should be successful + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.readEntity(String.class)).isEqualTo("Success"); + + // Verify that the AuthorizationVerifier was called with the correct annotation + assertThat(verifierCounter.get() - baseCount).isOne(); + } + + @Test + public void shouldNotApplyFilterWhenNotAnnotated() { + // Act: Call the test resource + int baseCount = verifierCounter.get(); + WebTarget target = target("/test-resource"); + Response response = target.request().get(); + + // Assert: The response should be successful + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.readEntity(String.class)).isEqualTo("Success"); + + // Verify that the AuthorizationVerifier was not called + assertThat(verifierCounter.get() - baseCount).isZero(); + } +} + diff --git a/plume-web-jersey/src/test/java/com/coreoz/plume/jersey/security/basic/BasicAuthenticatorTest.java b/plume-web-jersey/src/test/java/com/coreoz/plume/jersey/security/basic/BasicAuthenticatorTest.java new file mode 100644 index 0000000..9bf7ebf --- /dev/null +++ b/plume-web-jersey/src/test/java/com/coreoz/plume/jersey/security/basic/BasicAuthenticatorTest.java @@ -0,0 +1,87 @@ +package com.coreoz.plume.jersey.security.basic; + +import com.google.common.net.HttpHeaders; +import jakarta.ws.rs.ClientErrorException; +import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.container.ContainerRequestContext; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; + +import java.util.Base64; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class BasicAuthenticatorTest { + private static final String REALM = "TestRealm"; + private static final String VALID_USERNAME = "user"; + private static final String VALID_PASSWORD = "password"; + private static final String INVALID_USERNAME = "wrongUser"; + private static final String INVALID_PASSWORD = "wrongPassword"; + + private ContainerRequestContext mockRequestContext; + + @Before + public void setUp() { + mockRequestContext = Mockito.mock(ContainerRequestContext.class); + } + + // Test for requireAuthentication() + @Test + public void requireAuthentication__when_validCredentials__should_returnBasicUser() { + BasicAuthenticator authenticator = BasicAuthenticator.fromSingleCredentials(VALID_USERNAME, VALID_PASSWORD, REALM); + // Mock the request context to simulate a valid header + when(mockRequestContext.getHeaderString(HttpHeaders.AUTHORIZATION)).thenReturn(createBasicAuthHeader(VALID_USERNAME, VALID_PASSWORD)); + String result = authenticator.requireAuthentication(mockRequestContext); + + assertThat(result).isEqualTo("Basic user"); + } + + @Test + public void requireAuthentication__when_invalidCredentials__should_throw_forbidden_exception() { + BasicAuthenticator authenticator = BasicAuthenticator.fromSingleCredentials(VALID_USERNAME, VALID_PASSWORD, REALM); + when(mockRequestContext.getHeaderString(HttpHeaders.AUTHORIZATION)).thenReturn(createBasicAuthHeader(INVALID_USERNAME, INVALID_PASSWORD)); + + assertThatThrownBy(() -> authenticator.requireAuthentication(mockRequestContext)) + .isInstanceOf(ForbiddenException.class); + } + + @Test + public void requireAuthentication__when_missing_authorization_header__should_throw_client_exception() { + BasicAuthenticator authenticator = BasicAuthenticator.fromSingleCredentials(VALID_USERNAME, VALID_PASSWORD, REALM); + when(mockRequestContext.getHeaderString(HttpHeaders.AUTHORIZATION)).thenReturn(null); + + assertThatThrownBy(() -> authenticator.requireAuthentication(mockRequestContext)) + .isInstanceOf(ClientErrorException.class); + } + + // Test for parseBasicHeader() + @Test + public void parseBasicHeader__when_validHeader__should_returnCredentials() { + String validAuthHeader = createBasicAuthHeader(VALID_USERNAME, VALID_PASSWORD); + Credentials credentials = BasicAuthenticator.parseBasicHeader(validAuthHeader); + + assertThat(credentials).isNotNull(); + assertThat(credentials.getUsername()).isEqualTo(VALID_USERNAME); + assertThat(credentials.getPassword()).isEqualTo(VALID_PASSWORD); + } + + @Test + public void parseBasicHeader__when_invalidHeader__should_returnNull() { + String invalidAuthHeader = "InvalidHeaderFormat"; + Credentials credentials = BasicAuthenticator.parseBasicHeader(invalidAuthHeader); + + assertThat(credentials).isNull(); + } + + // Helper to create a Basic Auth header + private static String createBasicAuthHeader(String username, String password) { + String auth = username + ":" + password; + return "Basic " + Base64.getEncoder().encodeToString(auth.getBytes()); + } +} diff --git a/plume-web-jersey/src/test/java/com/coreoz/plume/jersey/security/bearer/BearerAuthenticatorTest.java b/plume-web-jersey/src/test/java/com/coreoz/plume/jersey/security/bearer/BearerAuthenticatorTest.java new file mode 100644 index 0000000..d781df0 --- /dev/null +++ b/plume-web-jersey/src/test/java/com/coreoz/plume/jersey/security/bearer/BearerAuthenticatorTest.java @@ -0,0 +1,89 @@ +package com.coreoz.plume.jersey.security.bearer; + +import com.google.common.net.HttpHeaders; +import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.container.ContainerRequestContext; +import org.junit.Before; +import org.junit.Test; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +public class BearerAuthenticatorTest { + + private static final String VALID_BEARER_TOKEN = "validBearerToken"; + private static final String VALID_AUTH_HEADER = "Bearer " + VALID_BEARER_TOKEN; + private static final String INVALID_BEARER_TOKEN = "invalidBearerToken"; + private BearerAuthenticator authenticator; + + @Before + public void setUp() { + authenticator = new BearerAuthenticator(VALID_BEARER_TOKEN); + } + + // Test for parseBearerHeader() method + + @Test + public void parseBearerHeader__when_authorizationHeader_is_null__should_return_null() { + String result = BearerAuthenticator.parseBearerHeader(null); + assertThat(result).isNull(); + } + + @Test + public void parseBearerHeader__when_authorizationHeader_does_not_start_with_bearer__should_return_null() { + String result = BearerAuthenticator.parseBearerHeader("Basic abc123"); + assertThat(result).isNull(); + } + + @Test + public void parseBearerHeader__when_authorizationHeader_starts_with_bearer__should_return_token() { + String result = BearerAuthenticator.parseBearerHeader(VALID_AUTH_HEADER); + assertThat(result).isEqualTo(VALID_AUTH_HEADER); + } + + // Test for verifyAuthentication() method + + @Test + public void verifyAuthentication__when_authorizationHeader_is_valid__should_not_throw_exception() { + // Mock the request context to simulate a valid header + ContainerRequestContext requestContext = mock(ContainerRequestContext.class); + when(requestContext.getHeaderString(HttpHeaders.AUTHORIZATION)).thenReturn(VALID_AUTH_HEADER); + + // Verify that no exception is thrown + assertThatCode(() -> authenticator.verifyAuthentication(requestContext)) + .doesNotThrowAnyException(); + } + + @Test + public void verifyAuthentication__when_authorizationHeader_is_null__should_throw_forbiddenException() { + // Mock the request context with a null authorization header + ContainerRequestContext requestContext = mock(ContainerRequestContext.class); + when(requestContext.getHeaderString(HttpHeaders.AUTHORIZATION)).thenReturn(null); + + // Verify that a ForbiddenException is thrown + assertThatThrownBy(() -> authenticator.verifyAuthentication(requestContext)) + .isInstanceOf(ForbiddenException.class); + } + + @Test + public void verifyAuthentication__when_authorizationHeader_does_not_start_with_bearer__should_throw_forbiddenException() { + // Mock the request context with an invalid authorization header + ContainerRequestContext requestContext = mock(ContainerRequestContext.class); + when(requestContext.getHeaderString(HttpHeaders.AUTHORIZATION)).thenReturn("Basic abc123"); + + // Verify that a ForbiddenException is thrown + assertThatThrownBy(() -> authenticator.verifyAuthentication(requestContext)) + .isInstanceOf(ForbiddenException.class); + } + + @Test + public void verifyAuthentication__when_authorizationHeader_is_invalid__should_throw_forbiddenException() { + // Mock the request context with an invalid bearer token + ContainerRequestContext requestContext = mock(ContainerRequestContext.class); + when(requestContext.getHeaderString(HttpHeaders.AUTHORIZATION)).thenReturn("Bearer " + INVALID_BEARER_TOKEN); + + // Verify that a ForbiddenException is thrown + assertThatThrownBy(() -> authenticator.verifyAuthentication(requestContext)) + .isInstanceOf(ForbiddenException.class); + } +} diff --git a/plume-web-jersey/src/test/resources/logback.xml b/plume-web-jersey/src/test/resources/logback.xml new file mode 100644 index 0000000..e84ab3d --- /dev/null +++ b/plume-web-jersey/src/test/resources/logback.xml @@ -0,0 +1,14 @@ + + + + + %d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n + + + + + + + + +