3

I am quite new to the Spring ecosystem in general and Webflux. There are 2 things that I am trying to figure out and cannot find any specifics about.

My Setup:

I am writing a Spring Boot 2 REST API using WebFlux (not using controllers but rather handler functions). The authentication server is a separate service which issues JWT tokens and those get attached to each request as Authentication headers. Here is a simple example of a request method:

public Mono<ServerResponse> all(ServerRequest serverRequest) {
        return principal(serverRequest).flatMap(principal ->
                ReactiveResponses.listResponse(this.projectService.all(principal)));
    }

Which i use to react to a GET request for a list of all "Projects" that a user has access to.

I afterwards have a service which retrieves the list of projects for this user and i render a json response.

The Problems:

Now in order to filter the projects based on the current user id i need to read it from the request principal. One issue here is that i have plenty service methods which need the current user information and passing it through to the service seems like an overkill. One solution is to read the principal inside the service from:

Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();

Question 1:

Is this a good practice in general when writing functional code (If i do this instead of propagating the principal)? is this a good approach despite the complexity of reading and sending the principal from the request to the service in each method?

Question 2:

Should i instead use the SecurityContextHolder Thread Local to fetch the principal, and if i do that how do i write tests for my service?

If i use the Security Context how do i test my service implementations which are expecting a principal that is of type JWTAuthenticationToken JWTAuthenticationToken

and i always get null when trying to do something like described here: Unit testing with Spring Security

In the service tests, In tests what i've managed to do so far is to propagate the principal to the service methods and use mockito to mock the principal. This is quite straightforward. In the Endpoint Tests i am using @WithMockUser to populate the principal when doing requests and i mock out the service layer. This has the downside of the principal type being different.

Here is how my test class for the service layer looks:

@DataMongoTest
@Import({ProjectServiceImpl.class})
class ProjectServiceImplTest extends BaseServiceTest {

    @Autowired
    ProjectServiceImpl projectService;

    @Autowired
    ProjectRepository projectRepository;

    @Mock
    Principal principal;

    @Mock
    Principal principal2;

    @BeforeEach
    void setUp() {
        initMocks(this);

        when(principal.getName()).thenReturn("uuid");
        when(principal2.getName()).thenReturn("uuid2");
    }

    // Cleaned for brevity 

    @Test
    public void all_returnsOnlyOwnedProjects() {
        Flux<Project> saved = projectRepository.saveAll(
                Flux.just(
                        new Project(null, "First", "uuid"),
                        new Project(null, "Second", "uuid2"),
                        new Project(null, "Third", "uuid3")
                )
        );
        Flux<Project> all = projectService.all(principal2);
        Flux<Project> composite = saved.thenMany(all);

        StepVerifier
                .create(composite)
                .consumeNextWith(project -> {
                    assertThat(project.getOwnerUserId()).isEqualTo("uuid2");
                })
                .verifyComplete();
    }

}
DArkO
  • 15,880
  • 12
  • 60
  • 88

2 Answers2

5

Based on the other answer, i managed to solve this problem in the following way.

I added the following methods to read the id from claims where it normally resides within the JWT token.

    public static Mono<String> currentUserId() {
        return jwt().map(jwt -> jwt.getClaimAsString(USER_ID_CLAIM_NAME));
    }


    public static Mono<Jwt> jwt() {
        return ReactiveSecurityContextHolder.getContext()
                .map(context -> context.getAuthentication().getPrincipal())
                .cast(Jwt.class);
    }

Then i use this within my services wherever needed, and i am not forwarding it through the handler to the service.

The tricky part was always testing. I am able to resolve this using the custom SecurityContextFactory. I created an annotation which i can attach the same way as @WithMockUser, but with some of the claim details i need instead.

@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockTokenSecurityContextFactory.class)
public @interface WithMockToken {
    String sub() default "uuid";
    String email() default "test@test.com";
    String name() default "Test User";
}

Then the Factory:

String token = "....ANY_JWT_TOKEN_GOES_HERE";

    @Override
    public SecurityContext createSecurityContext(WithMockToken tokenAnnotation) {
        SecurityContext context = SecurityContextHolder.createEmptyContext();
        HashMap<String, Object> headers = new HashMap<>();
        headers.put("kid", "SOME_ID");
        headers.put("typ", "JWT");
        headers.put("alg", "RS256");
        HashMap<String, Object> claims = new HashMap<>();
        claims.put("sub", tokenAnnotation.sub());
        claims.put("aud", new ArrayList<>() {{
            add("SOME_ID_HERE");
        }});
        claims.put("updated_at", "2019-06-24T12:16:17.384Z");
        claims.put("nickname", tokenAnnotation.email().substring(0, tokenAnnotation.email().indexOf("@")));
        claims.put("name", tokenAnnotation.name());
        claims.put("exp", new Date());
        claims.put("iat", new Date());
        claims.put("email", tokenAnnotation.email());
        Jwt jwt = new Jwt(token, Instant.now(), Instant.now().plus(1, ChronoUnit.HOURS), headers,
                claims);
        JwtAuthenticationToken jwtAuthenticationToken = new JwtAuthenticationToken(jwt, AuthorityUtils.NO_AUTHORITIES); // Authorities are needed to pass authentication in the Integration tests
        context.setAuthentication(jwtAuthenticationToken);


        return context;
    }

Then a simple test will look like this:

    @Test
    @WithMockToken(sub = "uuid2")
    public void delete_whenNotOwner() {
        Mono<Void> deleted = this.projectService.create(projectDTO)
                .flatMap(saved -> this.projectService.delete(saved.getId()));

        StepVerifier
                .create(deleted)
                .verifyError(ProjectDeleteNotAllowedException.class);
    }

DArkO
  • 15,880
  • 12
  • 60
  • 88
4

As you are using Webflux you should be using the ReactiveSecurityContextHolder to retrieve the principal like so : Object principal = ReactiveSecurityContextHolder.getContext().getAuthentication().getPrincipal();

The use of the non-reactive one will return null as you are seeing.

There is more info related to the topic in this answer - https://stackoverflow.com/a/51350355/197342

McGin
  • 1,361
  • 2
  • 13
  • 31
  • Thanks a lot. I don't know how i missed this. It did lead me to the documentation and helped with the custom mock token. I will post my final solution below as reference. – DArkO Jun 24 '19 at 13:45
  • It leads to compile error in moder Spring because ReactiveSecurityContextHolder.getContext() returns Mono and it doesn't have getAuthentication() method and method block can't be called https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/core/context/ReactiveSecurityContextHolder.html#getContext() – gstackoverflow Dec 21 '22 at 13:17