vendredi 5 février 2021

Spring Boot Testing & Access Control: An Authentication object was not found in the SecurityContext

I want to test my Spring boot application with access control: users don't have to access to resources of others.

I have this User Entity:

@Entity
@Table(name = "USERS")
@Data
public class User implements Serializable {

    @Serial
    private static final long serialVersionUID = -6521572023157125222L;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true)
    @NotNull
    @Size(min = 4, max = 15)
    private String username;

    @Column
    @NotNull
    @JsonIgnore
    private String password;

    @OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true, mappedBy = "user")
    @JsonManagedReference(value = "user")
    private Set<Purchase> purchaseSet;

    @ManyToOne
    @JoinColumn(name = "role_id", nullable = false)
    @EqualsAndHashCode.Exclude
    @JsonBackReference
    private Role role;

    @ManyToMany(mappedBy = "userSet")
    @JsonManagedReference(value = "userSet")
    @EqualsAndHashCode.Exclude
    private Set<Group> sharedGroupsSet;
}

Let's focus on sharedGroupsSet: a list of groups where the user can be added (or add himself). The Group entity is the following (i can't name the table "group"...i don't know why but if the table name is different from "group" the scheme is correct, otherwise not (bad relationship and primary keys). So i called the table "teams". First question: Someone knows why with that name (using MySQL) doesn't it work?):

@Entity
@Table(name = "TEAMS")
@Data
public class Group implements Serializable {

    @Serial
    private static final long serialVersionUID = 1172517945693877297L;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotNull
    private String name;

    @NotNull
    private String description;

    @OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true, mappedBy = "group")
    @JsonManagedReference(value = "purchases_of_group")
    private Set<Purchase> purchaseSet;

    @ManyToMany(cascade = CascadeType.ALL)
    @JoinTable
    @JsonManagedReference(value = "userSet")
    private Set<User> userSet;

    @ManyToMany(cascade = CascadeType.ALL)
    @JoinTable
    @JsonManagedReference(value = "categorySet")
    private Set<Category> categorySet;
}

So i decided to add an authorization level (to grant Access Control) for the queries on db: i wrote an GroupAuthorizationService which is the following:

@Service
@Log
@Data
public class GroupAuthorizationService {

    @Autowired
    AuthorizationService authorizationService;

    public boolean hasAccess(Optional<Group> group) {
        if (authorizationService.isAuthorizationDisabled()){
            return true;
        }
        log.info("Controllo autorizzazioni per Optional<Group>");
        AuthenticatedUser authenticatedUser = (AuthenticatedUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        if (group.isEmpty()) {
            return true;
        } else {
            return group.get().getUserSet().stream().anyMatch(u -> u.getId().equals(authenticatedUser.getId()));
        }
    }

    public boolean hasAccess(Group group) {
        if (authorizationService.isAuthorizationDisabled()){
            return true;
        }

        if(group == null){
            return true;
        }

        log.info("Controllo autorizzazioni per gruppo " + group.getName());
        AuthenticatedUser authenticatedUser = (AuthenticatedUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        return group.getUserSet().stream().anyMatch(u -> u.getId().equals(authenticatedUser.getId()));
    }

    public boolean hasAccess(Iterable<Group> groups) {
        if (authorizationService.isAuthorizationDisabled()){
            return true;
        }
        log.info("Controllo autorizzazioni per la lista di utenti");
        AuthenticatedUser authenticatedUser = (AuthenticatedUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();

        boolean isCorrect = true;
        for (Group team : groups) {
            if (team.getUserSet().stream().noneMatch(u -> u.getId().equals(authenticatedUser.getId()))) {
                isCorrect = false;
                break;
            }
        }
        return isCorrect;
    }
}

And i use it in GroupRepository:

@Repository
@PostAuthorize("@groupAuthorizationService.hasAccess(returnObject)")
public interface GroupRepository extends JpaRepository<Group, Long> {

    Group findGroupByName(String name);

}

This service allows me to add the Access Control in my API: users can't access to data of all groups but only to their own. Access Control could be disabled also with the @Autowired AuthorizationService:

@Service
public class AuthorizationService {
    private boolean disableAuthorization;

    public void enableAuthorization(){
        this.disableAuthorization = false;
    }

    public void disableAuthorization(){
        this.disableAuthorization = true;
    }

    public boolean isAuthorizationDisabled(){
        return this.disableAuthorization;
    }

}

Second question: is it a valid approach? Using @PostAuthorize to the repository level I ensure that everywhere in the application is not possible to access to invalid resources. I have not found any better solution of using @PostAuthorize in all api methods so i created mine!

In the end I've implemented Authentication with a JWT filter:

@Component
@Log
public class JwtRequestFilter extends OncePerRequestFilter {

    @Autowired
    private UserService userService;

    @Autowired
    private JwtTokenUtil jwtTokenUtil;

    @Qualifier("handlerExceptionResolver")
    @Autowired
    private HandlerExceptionResolver resolver;

    @SneakyThrows
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ExpiredJwtException {
        final String requestTokenHeader = request.getHeader("Authorization");
        String username = null;
        String jwtToken = null;
        // JWT Token is in the form "Bearer token". Remove Bearer word and get
        // only the Token
        if (requestTokenHeader != null && requestTokenHeader.startsWith("Bearer ")) {
            jwtToken = requestTokenHeader.substring(7);
            try {
                username = jwtTokenUtil.getUsernameFromToken(jwtToken);
            } catch (CustomJwtException e) {
                log.warning("Errore nel filtro " + e.getMessage());
                resolver.resolveException(request, response, null, e);
                return;
            }
        } else {
            logger.warn("JWT Token does not begin with Bearer String");
        }
        // Once we get the token validate it.
        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            AuthenticatedUser authenticatedUser = this.userService.loadUserByUsername(username);
            // if token is valid configure Spring Security to manually set
            // authentication
            if (jwtTokenUtil.validateToken(jwtToken, authenticatedUser)) {
                UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(
                        authenticatedUser, null, authenticatedUser.getAuthorities());
                usernamePasswordAuthenticationToken
                        .setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                // After setting the Authentication in the context, we specify
                // that the current user is authenticated. So it passes the
                // Spring Security Configurations successfully.
                SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
            }
        }
        chain.doFilter(request, response);
    }
}

And added to WebSecurityConfig:

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(
        prePostEnabled = true,
        securedEnabled = true
)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;

    @Autowired
    private UserService userService;

    @Autowired
    private JwtRequestFilter jwtRequestFilter;

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        // configure AuthenticationManager so that it knows from where to load
        // user for matching credentials
        // Use BCryptPasswordEncoder
        auth.userDetailsService(userService).passwordEncoder(passwordEncoder());
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        // We don't need CSRF for this example
        httpSecurity.csrf().disable()
                // dont authenticate this particular request
                .authorizeRequests().antMatchers("/api/authenticate", "/api/register", "/", "/favicon.ico").permitAll()
                // all other requests need to be authenticated
                .anyRequest().authenticated().and()
                // make sure we use stateless session; session won't be used to store user's state.
                .exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint)
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        // Add a filter to validate the tokens with every request
        httpSecurity.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

The Api works fine! but if i want to test it I have some problems:

@ActiveProfiles("test")
@ContextConfiguration(classes = SpeseApiApplication.class)
@SpringBootTest
@Log
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
public class GroupControllerTest {

    @Autowired
    EntityManager entityManager;

    @Autowired
    private WebApplicationContext wac;

    @Autowired
    private GroupRepository groupRepository;

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private RoleRepository roleRepository;

    @Autowired
    private PasswordEncoder bcryptEncoder;

    @Autowired
    private AuthorizationService authorizationService;

    @Autowired
    private JwtRequestFilter jwtRequestFilter;

    private MockMvc mockMvc;

    private User user1;
    private User user2;
    private User user3;
    private String token;

    private final String username1 = "Riccardo 1";
    private final String password1 = "passw0rd1";
    private final String jsonUser1;

    private final String username2 = "Riccardo 2";
    private final String password2 = "passw0rd2";
    private final String jsonUser2;

    private final String username3 = "Riccardo 3";
    private final String password3 = "passw0rd3";

    private final String groupName1 = "Gruppo Riccardo 1";
    private final String groupDescription1 = "Descrizione Gruppo Riccardo 1";

    private final String groupName2 = "Gruppo Riccardo 2";
    private final String groupDescription2 = "Descrizione Gruppo Riccardo 2";
    private final String jsonGroup2;

    private final String groupName3 = "Gruppo Riccardo 3";
    private final String groupDescription3 = "Descrizione Gruppo Riccardo 3";
    private final String jsonGroup3;

    GroupControllerTest() throws JsonProcessingException {

        jsonUser1 = generateJsonUserDTO(username1, password1);
        jsonUser2 = generateJsonUserDTO(username2, password2);

        jsonGroup2 = generateJsonGroupDTO(
                groupName2,
                groupDescription2,
                new HashSet<>(Arrays.asList()),
                new HashSet<>(Arrays.asList(2))
        );

        jsonGroup3 = generateJsonGroupDTO(
                groupName3,
                groupDescription3,
                new HashSet<>(Arrays.asList()),
                new HashSet<>(Arrays.asList(2, 3))
        );
    }

    @BeforeEach
    public void setup() {
        //
        //SecurityContextHolder.getContext().setAuthentication(
        //        new AnonymousAuthenticationToken(
        //                "GUEST",
        //                "USERNAME",
        //                AuthorityUtils.createAuthorityList("GUEST")
        //        )
        //);

        authorizationService.disableAuthorization();
        user1 = new User();
        user1.setUsername(username1);
        user1.setPassword(bcryptEncoder.encode(password1));
        user1.setRole(roleRepository.findByName(RoleEnum.ROLE_USER));

        user2 = new User();
        user2.setUsername(username2);
        user2.setPassword(bcryptEncoder.encode(password2));
        user2.setRole(roleRepository.findByName(RoleEnum.ROLE_USER));

        user3 = new User();
        user3.setUsername(username3);
        user3.setPassword(bcryptEncoder.encode(password3));
        user3.setRole(roleRepository.findByName(RoleEnum.ROLE_USER));

        Group group = new Group();
        group.setName(groupName1);
        group.setDescription(groupDescription1);
        group.setUserSet(new HashSet<>(Arrays.asList(user1)));
        group.setCategorySet(new HashSet<>());
        group.setPurchaseSet(new HashSet<>());

        groupRepository.save(group);
        userRepository.save(user2);
        userRepository.save(user3);
        authorizationService.enableAuthorization();

        mockMvc = MockMvcBuilders
                .webAppContextSetup(wac)
                .addFilters(jwtRequestFilter)
                .build();

        // SecurityContextHolder.getContext().setAuthentication(null);
    }


    public void authenticateUser(String jsonUser) throws Exception {
        ResultActions resultActions = mockMvc.perform(MockMvcRequestBuilders.post("/api/authenticate")
                .contentType(MediaType.APPLICATION_JSON)
                .content(jsonUser)
                .accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.token").exists())
                .andDo(print());

        String resultString = resultActions.andReturn().getResponse().getContentAsString();

        JacksonJsonParser jsonParser = new JacksonJsonParser();
        token = jsonParser.parseMap(resultString).get("token").toString();
    }

    @Test
    public void createGroupTwoTimes() throws Exception {
        authenticateUser(jsonUser2);
        mockMvc.perform(MockMvcRequestBuilders.post("/api/groups/create")
                .header("authorization", "Bearer " + token)
                .contentType(MediaType.APPLICATION_JSON)
                .content(jsonGroup2)
                .accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isCreated())
                .andDo(print());


        mockMvc.perform(MockMvcRequestBuilders.post("/api/groups/create")
                .header("authorization", "Bearer " + token)
                .contentType(MediaType.APPLICATION_JSON)
                .content(jsonGroup2)
                .accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isNotAcceptable())
                .andDo(print());

        Group groupFromDb = groupRepository.findGroupByName(groupName2);
        assertThat(groupFromDb).extracting(Group::getName).isEqualTo(groupName2);
        assertThat(groupFromDb).extracting(Group::getDescription).isEqualTo(groupDescription2);
        assertEquals(2, groupFromDb.getId());
        assertEquals(0, groupFromDb.getPurchaseSet().size());
        assertEquals(0, groupFromDb.getCategorySet().size());
        assertEquals(1, groupFromDb.getUserSet().size());
        assertTrue(groupFromDb.getUserSet().stream().anyMatch(u ->
                u.getId().equals(user2.getId())
        ));
        assertTrue(groupFromDb.getUserSet().stream().noneMatch(u ->
                u.getId().equals(user1.getId())
        ));
        assertTrue(groupFromDb.getUserSet().stream().noneMatch(u ->
                u.getId().equals(user3.getId())
        ));


        mockMvc.perform(MockMvcRequestBuilders.post("/api/groups/create")
                .header("authorization", "Bearer " + token)
                .contentType(MediaType.APPLICATION_JSON)
                .content(jsonGroup3)
                .accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isCreated())
                .andDo(print());

        groupFromDb = groupRepository.findGroupByName(groupName3);
        assertThat(groupFromDb).extracting(Group::getName).isEqualTo(groupName3);
        assertThat(groupFromDb).extracting(Group::getDescription).isEqualTo(groupDescription3);
        assertEquals(3, groupFromDb.getId());
        assertEquals(0, groupFromDb.getPurchaseSet().size());
        assertEquals(0, groupFromDb.getCategorySet().size());
        assertEquals(2, groupFromDb.getUserSet().size());
        assertTrue(groupFromDb.getUserSet().stream().anyMatch(u ->
                u.getId().equals(user2.getId())
        ));
        assertTrue(groupFromDb.getUserSet().stream().anyMatch(u ->
                u.getId().equals(user3.getId())
        ));
        assertTrue(groupFromDb.getUserSet().stream().noneMatch(u ->
                u.getId().equals(user1.getId())
        ));
    }


    private String generateJsonGroupDTO(String groupName, String groupDescription, Set<Integer> purchaseSet, Set<Integer> usersSet) throws JsonProcessingException {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
        GroupDTO groupDTO = new GroupDTO();
        groupDTO.setName(groupName);
        groupDTO.setDescription(groupDescription);
        groupDTO.setPurchaseSet(purchaseSet);
        groupDTO.setUserSet(usersSet);
        return objectMapper.writeValueAsString(groupDTO);
    }

    private String generateJsonUserDTO(String username, String password) throws JsonProcessingException {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
        UserDTO userDTOOk = new UserDTO();
        userDTOOk.setUsername(username);
        userDTOOk.setPassword(password);
        return objectMapper.writeValueAsString(userDTOOk);
    }
}

If i run it i obtain this error:

org.springframework.security.authentication.AuthenticationCredentialsNotFoundException: An Authentication object was not found in the SecurityContext

    at org.springframework.security.access.intercept.AbstractSecurityInterceptor.credentialsNotFound(AbstractSecurityInterceptor.java:333)
    at org.springframework.security.access.intercept.AbstractSecurityInterceptor.beforeInvocation(AbstractSecurityInterceptor.java:200)
    at org.springframework.security.access.intercept.aopalliance.MethodSecurityInterceptor.invoke(MethodSecurityInterceptor.java:58)
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186)
    at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:215)
    at com.sun.proxy.$Proxy139.save(Unknown Source)
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:64)
    at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.base/java.lang.reflect.Method.invoke(Method.java:564)
    at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:344)
    at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:208)
    at com.sun.proxy.$Proxy108.save(Unknown Source)
    at gabellini.company.speseapi.GroupControllerTest.setup(GroupControllerTest.java:165)
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:64)
    at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.base/java.lang.reflect.Method.invoke(Method.java:564)
    at org.junit.platform.commons.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:688)
    at org.junit.jupiter.engine.execution.MethodInvocation.proceed(MethodInvocation.java:60)
    at org.junit.jupiter.engine.execution.InvocationInterceptorChain$ValidatingInvocation.proceed(InvocationInterceptorChain.java:131)
    at org.junit.jupiter.engine.extension.TimeoutExtension.intercept(TimeoutExtension.java:149)
    at org.junit.jupiter.engine.extension.TimeoutExtension.interceptLifecycleMethod(TimeoutExtension.java:126)
    at org.junit.jupiter.engine.extension.TimeoutExtension.interceptBeforeEachMethod(TimeoutExtension.java:76)
    at org.junit.jupiter.engine.execution.ExecutableInvoker$ReflectiveInterceptorCall.lambda$ofVoidMethod$0(ExecutableInvoker.java:115)
    at org.junit.jupiter.engine.execution.ExecutableInvoker.lambda$invoke$0(ExecutableInvoker.java:105)
    at org.junit.jupiter.engine.execution.InvocationInterceptorChain$InterceptedInvocation.proceed(InvocationInterceptorChain.java:106)
    at org.junit.jupiter.engine.execution.InvocationInterceptorChain.proceed(InvocationInterceptorChain.java:64)
    at org.junit.jupiter.engine.execution.InvocationInterceptorChain.chainAndInvoke(InvocationInterceptorChain.java:45)
    at org.junit.jupiter.engine.execution.InvocationInterceptorChain.invoke(InvocationInterceptorChain.java:37)
    at org.junit.jupiter.engine.execution.ExecutableInvoker.invoke(ExecutableInvoker.java:104)
    at org.junit.jupiter.engine.execution.ExecutableInvoker.invoke(ExecutableInvoker.java:98)
    at org.junit.jupiter.engine.descriptor.ClassBasedTestDescriptor.invokeMethodInExtensionContext(ClassBasedTestDescriptor.java:490)
    at org.junit.jupiter.engine.descriptor.ClassBasedTestDescriptor.lambda$synthesizeBeforeEachMethodAdapter$19(ClassBasedTestDescriptor.java:475)
    at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.lambda$invokeBeforeEachMethods$2(TestMethodTestDescriptor.java:167)
    at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.lambda$invokeBeforeMethodsOrCallbacksUntilExceptionOccurs$5(TestMethodTestDescriptor.java:195)
    at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
    at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.invokeBeforeMethodsOrCallbacksUntilExceptionOccurs(TestMethodTestDescriptor.java:195)
    at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.invokeBeforeEachMethods(TestMethodTestDescriptor.java:164)
    at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:127)
    at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:65)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$5(NodeTestTask.java:139)
    at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$7(NodeTestTask.java:129)
    at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:127)
    at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:126)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:84)

So i found an hack (but i don't think is a good solution):

I uncommented this lines

      //SecurityContextHolder.getContext().setAuthentication(
        //        new AnonymousAuthenticationToken(
        //                "GUEST",
        //                "USERNAME",
        //                AuthorityUtils.createAuthorityList("GUEST")
        //        )
        //);

And:

// SecurityContextHolder.getContext().setAuthentication(null);

To set an authenticated user (Anonymous). But I don't think this is a valid approach. Moreover I can't understand why I have to use:

mockMvc = MockMvcBuilders
                .webAppContextSetup(wac)
                .addFilters(jwtRequestFilter)
                .build();

Instead of using @AutoConfigureMockMvc and only

@Autowire
private MockMvc mockMvc;

So the Third question is: What's the best way to test my API? I want to write tests, involving first authentication with JWT, then a call to an endpoint ("api/groups/create") and in the end test if that Authenticated user has permission to create that resource). Note that a user can create a group only if his id is included in the userSet of group.

Let me know if i have to share more code for a better understanding Thank you in advance

Aucun commentaire:

Enregistrer un commentaire