javaspringspring-securityjunitspring-boot-test

Unable to use CustomUser using @WithUserDetails in Spring Boot test. Normal User class rather than CustomUser passed


I am trying to do something that should be quite simple, and that is have SpingBoot test pass in my CustomUserDetails for authentication during testing using @WithUserDetails.

The problem is the method being tested is always called with a standard org.springframework.security.core.userdetails.User rather than my CustomUserDetails.

I have implemented my own UserDetailsService and ascertained that it is being called. It returns an instance of my CustomUserDetails, yet only the normal User object is passed to my method.

I cannot for the life of me work out why.

The code works fine when I run it as an application. I just can't get it to work for my test cases with the test user.

My code is:

CustomUser class

@Data
public class CustomUserDetails extends User {

    private ZoneId zoneId;

    public CustomUserDetails(String username, String password,
        Collection<? extends GrantedAuthority> authorities) {            
        super(username, password, authorities);
    }

    public CustomUserDetails(String username, String password, boolean enabled, boolean  accountNonExpired, boolean credentialsNonExpired, boolean accountNonLocked, Collection<? extends GrantedAuthority> authorities) {
       super(username, password, enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, authorities);
    }

}

My test userDetailsService that returns an instance of the CustomUserDetails

@Bean
@Primary
public InMemoryUserDetailsManager myUserDetailsService() {
    CustomUserDetails basicUser = new CustomUserDetails("me@test.com", "password", true, true, true, true, Arrays.asList(new SimpleGrantedAuthority("ROLE_USER")));

    basicUser.setZoneId(ZoneId.of("UTC"));

    return new InMemoryUserDetailsManager(basicUser);
}

My actual test

@RunWith(SpringRunner.class)
@SpringBootTest(
    webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
    classes = SpringSecurityTestConfig.class
)
@AutoConfigureMockMvc(addFilters = false)
class UploadFileTest {

    @Autowired
    private MockMvc mockMvc;

     @Test
     @WithUserDetails(value="me@test.com", userDetailsServiceBeanName="myUserDetailsService")
     void doValidUploadFileNoParams() throws Exception {

         mockMvc.perform(get("/web/uploadFile"))
             .andExpect(status().isOk());

     }
}

My service method to test

@GetMapping("/uploadFile")
public String listUploadedFiles(@AuthenticationPrincipal CustomUserDetails userDetails, Model model) {
    List<ScanQueryFileUploadDetailsDTO> fileUploadList = storageService.findAllDetails();

//if users timeZone is set and is NOT UTC then change. If UTC then just leave unchanged.
    if((userDetails.getZoneId() != null) && (!userDetails.getZoneId().equals(ZoneId.of("UTC")))) {
        //convert times to match ZoneID of logged in user
        fileUploadList.forEach(f -> f.setUploadDateTime(convertDateTime(f.getUploadDateTime(), userDetails.getZoneId())));
    
    model.addAttribute("files", fileUploadList);

    return "uploadForm";
}

With the above the parameter CustomUserDetails userDetails will be null.

If I change this to an instance of org.springframework.security.core.userdetails.User then it is populated, but the property is NOT an instance of my CustomUserDetails and cannot be cast to the class.

I have ascertained that the myUserDetailsService is providing the bean as required. I have tried all sorts of different combinations of @annotations but nothing seems to change it.

I find it very strange that I populate my myUserDetailsService with a CustomUserDetails object, and that object is passed to my method, but only as the parent User and not with the extra details in my subclass.


Solution

  • Short answer

    You populated an instance of InMemoryUserDetailsManager with CustomUserDetails. But by design, InMemoryUserDetailsManager returns an instance of User from loadUserByUsername().

    To retrieve your CustomUserDetails from loadUserByUsername() you need to use instead a custom implementation of UserDetailsService or UserDetailsManager contracts (which will be the case in a real-life application because need to persist the users, instead of keeping user data in memory).

    Long Answer

    Let's have a more detailed look at what is happening while a test SecurityContext is being configured.

    @WithUserDetails()

    Prior to executing the test method, a newly created SecurityContext will be populated with an instance of Authentication. And the Principal in the Authention will be exactly the same instance of UserDetails that your UserDetailsService returns when loadUserByUsername() is called with the username you specified in the @WithUserDetails annotation.

    Take a look at the implementation of WithUserDetailsSecurityContextFactory.createSecurityContext() if you want to verify that what I'm saying is correct.

    So far, so good.

    InMemoryUserDetailsManager

    When your custom UserDetails implementation enters InMemoryUserDetailsManager it gets decorated by a MutableUser instance in order to facilitate password updates performed through UserDetailsManager.changePassword().

    public InMemoryUserDetailsManager(UserDetails... users) {
        for (UserDetails user : users) {
            createUser(user);
        }
    }
    
    @Override
    public void updateUser(UserDetails user) {
        Assert.isTrue(userExists(user.getUsername()), "user should exist");
        this.users.put(user.getUsername().toLowerCase(), new MutableUser(user));
    }
    

    And when you retrieve UserDetails using loadUserByUsername() it constructs an immutable User instance, ignoring all custom data the original UserDetails instance might have.

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserDetails user = this.users.get(username.toLowerCase());
        if (user == null) {
            throw new UsernameNotFoundException(username);
        }
        return new User(user.getUsername(), user.getPassword(), user.isEnabled(), user.isAccountNonExpired(),
                user.isCredentialsNonExpired(), user.isAccountNonLocked(), user.getAuthorities());
    }
    

    Hypnotically, it might have been implemented to return any UserDetails implementation if it knew how to construct it based on the initial instance and a new password (which can be done by providing a factory while instantiating a manager). But because InMemoryUserDetailsManager is only good for things like writing a simple demo and exploring the UserDetailsManager contract, probably the assumption was that no one will go beyond standard attributes.

    Anyway, InMemoryUserDetailsManager can give you only org.springframework.security.core.userdetails.User.

    Solution

    As I said before, you need to implement a custom UserDetailsManager (or UserDetailsService).

    Ultimately, you want to persist users, hence the implementation should be backed by a repository.

    If you just want to continue experimenting with Spring Security and don't want to deal with persistence for now, then you can create your own in-memory implementation.

    Below is an example of how UserDetailsService implementation might look like:

    public class CustomInMemoryUserDetailsService implements UserDetailsService {
        private final Map<String, UserDetails> userByName;
        
        public CustomInMemoryUserDetailsService(UserDetails... users) {
            this.userByName = Arrays.stream(users)
                .collect(toMap(
                    UserDetails::getUsername,
                    Function.identity()
                ));
        }
        
        @Override
        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
            var user = userByName.get(username);
            if (user == null) {
                throw new UsernameNotFoundException(username);
            }
            return user;
        }
    }
    

    Replacing InMemoryUserDetailsManager with CustomInMemoryUserDetailsService in the configuration class will resolve the issue:

    @Bean
    UserDetailsService myUserDetailsService() {
        var user = new CustomUserDetails(
            "me@test.com",
            "password",
            List.of(new SimpleGrantedAuthority("ROLE_USER"))
        );
        return new CustomInMemoryUserDetailsService(user);
    }
    

    UserDetailsService is simpler to implement because it defines only one method, but it's less capable than UserDetailsManager. If the reader is interested in using UserDetailsManager, then I'm leaving implementing this interface as a homework.

    By the way, why not assigning time zone ID to ZoneId.of("UTC") by default instead of performing null-checks? It's easier to avoid null-infestation, then to treat it.