Skip to content

Commit

Permalink
Make sure identity produced by @TestSecurity is augmented
Browse files Browse the repository at this point in the history
  • Loading branch information
michalvavrik committed Nov 19, 2024
1 parent 2b2dbe4 commit e6a895b
Show file tree
Hide file tree
Showing 15 changed files with 282 additions and 19 deletions.
44 changes: 44 additions & 0 deletions docs/src/main/asciidoc/security-testing.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,50 @@ public String getDetail() {
}
----

It is also possible to set custom permissions like in the example below:

[source,java]
----
@PermissionsAllowed("see", permission = CustomPermission.class)
public String getDetail() {
return "detail";
}
----

The `CustomPermission` needs to be granted to the `SecurityIdentity` created
by the `@TestSecurity` annotation with a `SecurityIdentityAugmentor` CDI bean:

[source,java]
----
@ApplicationScoped
public class CustomSecurityIdentityAugmentor implements SecurityIdentityAugmentor {
@Override
public Uni<SecurityIdentity> augment(SecurityIdentity securityIdentity,
AuthenticationRequestContext authenticationRequestContext) {
final SecurityIdentity augmentedIdentity;
if (shouldGrantCustomPermission(securityIdentity) {
augmentedIdentity = QuarkusSecurityIdentity.builder(securityIdentity)
.addPermission(new CustomPermission("see")).build();
} else {
augmentedIdentity = securityIdentity;
}
return Uni.createFrom().item(augmentedIdentity);
}
}
----

Quarkus will only augment the `SecurityIdentity` created with the `@TestSecurity` annotation if you set
the `@TestSecurity#augmentors` annotation attribute to the `CustomSecurityIdentityAugmentor.class` like this:

[source,java]
----
@Test
@TestSecurity(user = "testUser", permissions = "see:detail", augmentors = CustomSecurityIdentityAugmentor.class)
void someTestMethod() {
...
}
----

=== Mixing security tests

If it becomes necessary to test security features using both `@TestSecurity` and Basic Auth (which is the fallback auth
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
import io.quarkus.security.deployment.PermissionSecurityChecks.PermissionSecurityChecksBuilder;
import io.quarkus.security.identity.SecurityIdentityAugmentor;
import io.quarkus.security.runtime.IdentityProviderManagerCreator;
import io.quarkus.security.runtime.QuarkusPermissionSecurityIdentityAugmentor;
import io.quarkus.security.runtime.QuarkusSecurityRolesAllowedConfigBuilder;
import io.quarkus.security.runtime.SecurityBuildTimeConfig;
import io.quarkus.security.runtime.SecurityCheckRecorder;
Expand Down Expand Up @@ -691,7 +692,8 @@ void configurePermissionCheckers(PermissionSecurityChecksBuilderBuildItem checke
// - this processor relies on the bean archive index (cycle: idx -> additional bean -> idx)
// - we have injection points (=> better validation from Arc) as checker beans are only requested from this augmentor
var syntheticBeanConfigurator = SyntheticBeanBuildItem
.configure(SecurityIdentityAugmentor.class)
.configure(QuarkusPermissionSecurityIdentityAugmentor.class)
.addType(SecurityIdentityAugmentor.class)
// ATM we do get augmentors from CDI once, no need to keep the instance in the CDI container
.scope(Dependent.class)
.unremovable()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* Adds a permission checker that grants access to the {@link QuarkusPermission}
* when {@link QuarkusPermission#isGranted(SecurityIdentity)} is true.
*/
final class QuarkusPermissionSecurityIdentityAugmentor implements SecurityIdentityAugmentor {
public final class QuarkusPermissionSecurityIdentityAugmentor implements SecurityIdentityAugmentor {

/**
* Permission checker only authorizes authenticated users and checkers shouldn't throw a security exception.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
import io.quarkus.runtime.annotations.Recorder;
import io.quarkus.security.ForbiddenException;
import io.quarkus.security.StringPermission;
import io.quarkus.security.identity.SecurityIdentityAugmentor;
import io.quarkus.security.runtime.interceptor.SecurityCheckStorageBuilder;
import io.quarkus.security.runtime.interceptor.SecurityConstrainer;
import io.quarkus.security.runtime.interceptor.check.AuthenticatedCheck;
Expand Down Expand Up @@ -436,10 +435,11 @@ private static Object convertMethodParamToPermParam(int i, Object methodArg,
}
}

public Function<SyntheticCreationalContext<SecurityIdentityAugmentor>, SecurityIdentityAugmentor> createPermissionAugmentor() {
return new Function<SyntheticCreationalContext<SecurityIdentityAugmentor>, SecurityIdentityAugmentor>() {
public Function<SyntheticCreationalContext<QuarkusPermissionSecurityIdentityAugmentor>, QuarkusPermissionSecurityIdentityAugmentor> createPermissionAugmentor() {
return new Function<SyntheticCreationalContext<QuarkusPermissionSecurityIdentityAugmentor>, QuarkusPermissionSecurityIdentityAugmentor>() {
@Override
public SecurityIdentityAugmentor apply(SyntheticCreationalContext<SecurityIdentityAugmentor> ctx) {
public QuarkusPermissionSecurityIdentityAugmentor apply(
SyntheticCreationalContext<QuarkusPermissionSecurityIdentityAugmentor> ctx) {
return new QuarkusPermissionSecurityIdentityAugmentor(ctx.getInjectedReference(BlockingSecurityExecutor.class));
}
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
import jakarta.ws.rs.core.SecurityContext;

import io.quarkus.security.Authenticated;
import io.quarkus.security.PermissionChecker;
import io.quarkus.security.PermissionsAllowed;
import io.quarkus.security.identity.SecurityIdentity;

@Path("/")
Expand Down Expand Up @@ -73,4 +75,19 @@ public String getAttributes() {
.map(e -> e.getKey() + "=" + e.getValue())
.collect(Collectors.joining(","));
}

@GET
@Path("/test-security-permission-checker")
@PermissionsAllowed("see-principal")
public String getPrincipal(@Context SecurityContext sec) {
return sec.getUserPrincipal().getName() + ":" + identity.getPrincipal().getName() + ":" + principal.getName();
}

@PermissionChecker("see-principal")
boolean canSeePrincipal(SecurityContext sec) {
if (sec.getUserPrincipal() == null || sec.getUserPrincipal().getName() == null) {
return false;
}
return "meat loaf".equals(sec.getUserPrincipal().getName());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import jakarta.inject.Inject;

import org.hamcrest.Matchers;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
Expand All @@ -23,6 +24,7 @@
import io.quarkus.test.security.AttributeType;
import io.quarkus.test.security.SecurityAttribute;
import io.quarkus.test.security.TestSecurity;
import io.restassured.RestAssured;

@QuarkusTest
class TestSecurityTestCase {
Expand Down Expand Up @@ -187,4 +189,24 @@ static Stream<Arguments> arrayParams() {
arguments(new int[] { 1, 2 }, new String[] { "hello", "world" }));
}

@Test
public void testPermissionChecker_anonymousUser() {
// user is not authenticated and access should not be granted by the permission checker
RestAssured.get("/test-security-permission-checker").then().statusCode(401);
}

@Test
@TestSecurity(user = "authenticated-user")
public void testPermissionChecker_authenticatedUser() {
// user is authenticated, but access should not be granted by the permission checker
RestAssured.get("/test-security-permission-checker").then().statusCode(403);
}

@Test
@TestSecurity(user = "meat loaf")
public void testPermissionChecker_authorizedUser() {
// user is authenticated and access should be granted by the permission checker
RestAssured.get("/test-security-permission-checker").then().statusCode(200)
.body(Matchers.is("meat loaf:meat loaf:meat loaf"));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package io.quarkus.it.keycloak;

import java.security.BasicPermission;

public class CustomPermission extends BasicPermission {

public CustomPermission(String name) {
super(name);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import org.eclipse.microprofile.jwt.JsonWebToken;

import io.quarkus.security.Authenticated;
import io.quarkus.security.PermissionsAllowed;
import io.quarkus.security.identity.SecurityIdentity;

@Path("/web-app")
Expand All @@ -40,6 +41,14 @@ public String testSecurity() {
+ principal.getName();
}

@GET
@Path("test-security-with-augmentors")
@PermissionsAllowed(permission = CustomPermission.class, value = "augmented")
public String testSecurityWithAugmentors() {
return securityContext.getUserPrincipal().getName() + ":" + identity.getPrincipal().getName() + ":"
+ principal.getName();
}

@POST
@Path("test-security")
@Consumes("application/json")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package io.quarkus.it.keycloak;

import jakarta.enterprise.context.ApplicationScoped;

import io.quarkus.security.identity.AuthenticationRequestContext;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.security.identity.SecurityIdentityAugmentor;
import io.quarkus.security.runtime.QuarkusSecurityIdentity;
import io.smallrye.mutiny.Uni;

@ApplicationScoped
public class TestSecurityIdentityAugmentor implements SecurityIdentityAugmentor {

private static volatile boolean invoked = false;

@Override
public Uni<SecurityIdentity> augment(SecurityIdentity securityIdentity,
AuthenticationRequestContext authenticationRequestContext) {
invoked = true;
final SecurityIdentity identity;
if (securityIdentity.isAnonymous() || !"authorized-user".equals(securityIdentity.getPrincipal().getName())) {
identity = securityIdentity;
} else {
identity = QuarkusSecurityIdentity.builder(securityIdentity)
.addPermission(new CustomPermission("augmented")).build();
}
return Uni.createFrom().item(identity);
}

public static boolean isInvoked() {
return invoked;
}

public static void resetInvoked() {
invoked = false;
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
quarkus.keycloak.devservices.enabled=false

mp.jwt.verify.publickey.location=${keycloak.url}/realms/quarkus/protocol/openid-connect/certs
mp.jwt.verify.issuer=${keycloak.url}/realms/quarkus
smallrye.jwt.path.groups=realm_access/roles
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

import io.quarkus.test.common.http.TestHTTPEndpoint;
Expand All @@ -21,6 +22,53 @@
@TestHTTPEndpoint(ProtectedJwtResource.class)
public class TestSecurityLazyAuthTest {

@Test
public void testTestSecurityAnnotationWithAugmentors_anonymousUser() {
TestSecurityIdentityAugmentor.resetInvoked();
// user is not authenticated and doesn't have required role granted by the augmentor
RestAssured.get("test-security-with-augmentors").then().statusCode(401);
// identity manager applies augmentors on anonymous identity
// because @TestSecurity is not in action and that's what we do for the anonymous requests
Assertions.assertTrue(TestSecurityIdentityAugmentor.isInvoked());
}

@TestSecurity(user = "authenticated-user")
@Test
public void testTestSecurityAnnotationNoAugmentors_authenticatedUser() {
TestSecurityIdentityAugmentor.resetInvoked();
// user is authenticated, but doesn't have required role granted by the augmentor
// and no augmentors are applied
RestAssured.get("test-security-with-augmentors").then().statusCode(403);
Assertions.assertFalse(TestSecurityIdentityAugmentor.isInvoked());
}

@TestSecurity(user = "authenticated-user", augmentors = TestSecurityIdentityAugmentor.class)
@Test
public void testTestSecurityAnnotationWithAugmentors_authenticatedUser() {
TestSecurityIdentityAugmentor.resetInvoked();
// user is authenticated, but doesn't have required role granted by the augmentor
RestAssured.get("test-security-with-augmentors").then().statusCode(403);
Assertions.assertTrue(TestSecurityIdentityAugmentor.isInvoked());
}

@TestSecurity(user = "authorized-user")
@Test
public void testTestSecurityAnnotationNoAugmentors_authorizedUser() {
// should fail because no augmentors are applied
TestSecurityIdentityAugmentor.resetInvoked();
RestAssured.get("test-security-with-augmentors").then().statusCode(403);
Assertions.assertFalse(TestSecurityIdentityAugmentor.isInvoked());
}

@TestSecurity(user = "authorized-user", augmentors = TestSecurityIdentityAugmentor.class)
@Test
public void testTestSecurityAnnotationWithAugmentors_authorizedUser() {
TestSecurityIdentityAugmentor.resetInvoked();
RestAssured.get("test-security-with-augmentors").then().statusCode(200)
.body(is("authorized-user:authorized-user:authorized-user"));
Assertions.assertTrue(TestSecurityIdentityAugmentor.isInvoked());
}

@Test
@TestAsUser1Viewer
public void testWithDummyUser() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
package io.quarkus.test.security;

import static io.quarkus.vertx.http.runtime.security.HttpSecurityUtils.ROUTING_CONTEXT_ATTRIBUTE;

import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Supplier;

import jakarta.annotation.PostConstruct;
import jakarta.enterprise.inject.Instance;
import jakarta.inject.Inject;

import io.quarkus.runtime.LaunchMode;
import io.quarkus.security.identity.AuthenticationRequestContext;
import io.quarkus.security.identity.IdentityProviderManager;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.security.identity.SecurityIdentityAugmentor;
import io.quarkus.security.identity.request.AuthenticationRequest;
import io.quarkus.security.spi.runtime.BlockingSecurityExecutor;
import io.quarkus.vertx.http.runtime.security.ChallengeData;
import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism;
import io.quarkus.vertx.http.runtime.security.HttpCredentialTransport;
Expand All @@ -21,7 +30,11 @@ abstract class AbstractTestHttpAuthenticationMechanism implements HttpAuthentica
@Inject
TestIdentityAssociation testIdentityAssociation;

@Inject
BlockingSecurityExecutor blockingSecurityExecutor;

protected volatile String authMechanism = null;
protected volatile List<Instance<? extends SecurityIdentityAugmentor>> augmentors = null;

@PostConstruct
public void check() {
Expand All @@ -32,8 +45,21 @@ public void check() {
}

@Override
public Uni<SecurityIdentity> authenticate(RoutingContext context, IdentityProviderManager identityProviderManager) {
return Uni.createFrom().item(testIdentityAssociation.getTestIdentity());
public Uni<SecurityIdentity> authenticate(RoutingContext event, IdentityProviderManager identityProviderManager) {
var identity = Uni.createFrom().item(testIdentityAssociation.getTestIdentity());
if (augmentors != null && testIdentityAssociation.getTestIdentity() != null) {
var requestContext = new AuthenticationRequestContext() {
@Override
public Uni<SecurityIdentity> runBlocking(Supplier<SecurityIdentity> supplier) {
return blockingSecurityExecutor.executeBlocking(supplier);
}
};
var requestAttributes = Map.<String, Object> of(ROUTING_CONTEXT_ATTRIBUTE, event);
for (var augmentor : augmentors) {
identity = identity.flatMap(i -> augmentor.get().augment(i, requestContext, requestAttributes));
}
}
return identity;
}

@Override
Expand All @@ -55,4 +81,8 @@ public Uni<HttpCredentialTransport> getCredentialTransport(RoutingContext contex
void setAuthMechanism(String authMechanism) {
this.authMechanism = authMechanism;
}

void setSecurityIdentityAugmentors(List<Instance<? extends SecurityIdentityAugmentor>> augmentors) {
this.augmentors = augmentors;
}
}
Loading

0 comments on commit e6a895b

Please sign in to comment.