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.
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).
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
.
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.