Spring-security-oauth: OAuth2 "UserDetailsService is required" refresh token when use Custom Authentication Provider

Created on 5 Nov 2019  路  14Comments  路  Source: spring-projects/spring-security-oauth

Summary

I'm implementing OAuth2 Authorization server, and I have multiples authorizations providers, without User Details Service, like #813 issue, but when I try to get a refresh token of passord grant they return the error 500 with then Exception bellow

Actual Behavior

Error 500 with then Exception bellow

java.lang.IllegalStateException: UserDetailsService is required.
    at org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter$UserDetailsServiceDelegator.loadUserByUsername(WebSecurityConfigurerAdapter.java:462) ~[spring-security-config-5.2.0.RELEASE.jar:5.2.0.RELEASE]
    at org.springframework.security.core.userdetails.UserDetailsByNameServiceWrapper.loadUserDetails(UserDetailsByNameServiceWrapper.java:68) ~[spring-security-core-5.2.0.RELEASE.jar:5.2.0.RELEASE]
    at org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationProvider.authenticate(PreAuthenticatedAuthenticationProvider.java:103) ~[spring-security-web-5.2.0.RELEASE.jar:5.2.0.RELEASE]
    at org.springframework.security.authentication.ProviderManager.authenticate(ProviderManager.java:175) ~[spring-security-core-5.2.0.RELEASE.jar:5.2.0.RELEASE]
    at org.springframework.security.oauth2.provider.token.DefaultTokenServices.refreshAccessToken(DefaultTokenServices.java:150) ~[spring-security-oauth2-2.3.7.RELEASE.jar:na]
    at org.springframework.security.oauth2.provider.refresh.RefreshTokenGranter.getAccessToken(RefreshTokenGranter.java:47) ~[spring-security-oauth2-2.3.7.RELEASE.jar:na]
    at org.springframework.security.oauth2.provider.token.AbstractTokenGranter.grant(AbstractTokenGranter.java:67) ~[spring-security-oauth2-2.3.7.RELEASE.jar:na]
    at org.springframework.security.oauth2.provider.CompositeTokenGranter.grant(CompositeTokenGranter.java:38) ~[spring-security-oauth2-2.3.7.RELEASE.jar:na]
    at org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer$4.grant(AuthorizationServerEndpointsConfigurer.java:583) ~[spring-security-oauth2-2.3.7.RELEASE.jar:na]
    at org.springframework.security.oauth2.provider.endpoint.TokenEndpoint.postAccessToken(TokenEndpoint.java:132) ~[spring-security-oauth2-2.3.7.RELEASE.jar:na]
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:na]
    at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
    at java.base/java.lang.reflect.Method.invoke(Method.java:566) ~[na:na]

Test Result

MockHttpServletRequest:
      HTTP Method = POST
      Request URI = /oauth/token
       Parameters = {grant_type=[refresh_token], refresh_token=[f59f10ef-7b91-4c6d-8fea-ded4674a40a5]}
          Headers = [Authorization:"Basic Y2xpZW50X3B3ZF90ZXN0OnNlY3JldA=="]
             Body = null
    Session Attrs = {}

Handler:
             Type = org.springframework.security.oauth2.provider.endpoint.TokenEndpoint
           Method = org.springframework.security.oauth2.provider.endpoint.TokenEndpoint#postAccessToken(Principal, Map)

Async:
    Async started = false
     Async result = null

Resolved Exception:
             Type = java.lang.IllegalStateException

ModelAndView:
        View name = null
             View = null
            Model = null

FlashMap:
       Attributes = null

MockHttpServletResponse:
           Status = 500
    Error message = null
          Headers = [Vary:"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers", Cache-Control:"no-store", Pragma:"no-cache", Content-Type:"application/json", X-Content-Type-Options:"nosniff", X-XSS-Protection:"1; mode=block", X-Frame-Options:"DENY"]
     Content type = application/json
             Body = {"error":"server_error","error_description":"Internal Server Error"}
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

Expected Behavior

Refresh token generated with success

Configuration

@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

    @Autowired
    private DataSource dataSource;

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private DbAuthenticationProvider dbAuthenticationProvider;

    @Bean
    public TokenStore tokenStore() {
        return new JdbcTokenStore(dataSource);
    }

    @Bean
    public ApprovalStore approvalStore() {
        return new JdbcApprovalStore(dataSource);
    }

    @Bean
    public AuthorizationCodeServices authorizationCodeServices() {
        return new JdbcAuthorizationCodeServices(dataSource);
    }

    @Bean
    public JdbcClientDetailsService jdbcClientDetailsService() {
        JdbcClientDetailsService jdbcClientDetailsService = new JdbcClientDetailsService(dataSource);
        jdbcClientDetailsService.setPasswordEncoder(passwordEncoder());
        return jdbcClientDetailsService;
    }

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

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients
                .withClientDetails(jdbcClientDetailsService());
    }

    @Override
    public void configure(AuthorizationServerSecurityConfigurer oauthServer) {
        oauthServer
                .tokenKeyAccess("permitAll()")
                .checkTokenAccess("isAuthenticated()");
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
        endpoints
                .authenticationManager(authenticationManager)
                .approvalStore(approvalStore())
                .authorizationCodeServices(authorizationCodeServices())
                .tokenStore(tokenStore())
        ;
    }
}


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

    @Autowired
    private DbAuthenticationProvider dbAuthenticationProvider;

    @Override
    public void configure(WebSecurity web) {
        web.ignoring()
                .antMatchers("/js/**", "/css/**", "/img/**");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .cors().disable()
                .csrf().disable()
                .authorizeRequests()
                .antMatchers("/password/**").permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin().loginPage("/login").permitAll()
                .and()
                .logout().permitAll();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth
                .authenticationProvider(dbAuthenticationProvider);
    }

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

}

Version

spring boot 2.2.0.RELEASE
spring security oauth2 autoconfigure 2.2.0.RELEASE (spring security oauth2 2.3.7.RELEASE)

Sample

I use the same approach of stackoverflow but with modifications to solve my problem

On my AuthenticationProvider I add support to PreAuthenticatedAuthenticationToken

@Component
public class DbAuthenticationProvider implements AuthenticationProvider {

    @Autowired
    private UserCredentialsRepository credentialRepository;

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

        String username = authentication.getName();

        UserCredentials credentials = credentialRepository.findByName(username)
                .orElseThrow(() -> new BadCredentialsException("Invalid username and/or password"));

        List<GrantedAuthority> authorities = null;

        if (credentials.getAuthorities() != null) {
            authorities = credentials.getAuthorities().stream()
                    .map(it -> new SimpleGrantedAuthority(it.getAuthority()))
                    .collect(Collectors.toList());
        }

        if (authentication instanceof UsernamePasswordAuthenticationToken) {
            if (passwordEncoder.matches(authentication.getCredentials().toString(), credentials.getPassword())) {
                return new UsernamePasswordAuthenticationToken(authentication.getName(), authentication.getCredentials(), authorities);
            }
        } else if (authentication instanceof PreAuthenticatedAuthenticationToken) {
            return new PreAuthenticatedAuthenticationToken(authentication.getName(), authentication.getCredentials(), authorities);
        }

        throw new BadCredentialsException("Invalid username and/or password");
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.equals(UsernamePasswordAuthenticationToken.class)
                || authentication.equals(PreAuthenticatedAuthenticationToken.class);
    }
}

and on my AuthorizationServerConfig I add my own tokenServices

@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

    ....

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
        endpoints
                .authenticationManager(authenticationManager)
                .approvalStore(approvalStore())
                .authorizationCodeServices(authorizationCodeServices())
                .tokenStore(tokenStore())
                .tokenServices(tokenServices(endpoints)) 
        ;
    }

    private AuthorizationServerTokenServices tokenServices(final AuthorizationServerEndpointsConfigurer endpoints) {
        final DefaultTokenServices tokenServices = new DefaultTokenServices();

        tokenServices.setTokenStore(endpoints.getTokenStore());
        tokenServices.setSupportRefreshToken(true);
        tokenServices.setReuseRefreshToken(true);
        tokenServices.setClientDetailsService(endpoints.getClientDetailsService());
        tokenServices.setTokenEnhancer(endpoints.getTokenEnhancer());
        tokenServices.setAuthenticationManager(authenticationManager);
        return tokenServices;
    }

}

but I belived that is a Issue because this wr

Most helpful comment

I face similar issue at my current project, I think this problem must be treated with High Priority since there are many cases where you need Custom Authentication Provider in order to authenticate with both Username and Password.

All 14 comments

I face similar issue at my current project, I think this problem must be treated with High Priority since there are many cases where you need Custom Authentication Provider in order to authenticate with both Username and Password.

I came accross this issue looking for something else. Anyway, maybe you just need to override the method WebSecurityConfigurerAdapter.userDetailsServiceBean, like you did with the authenticationManagerBean. The JavaDoc says:

Override this method to expose a UserDetailsService created from configure(AuthenticationManagerBuilder) as a bean. In general only the following override should be done of this method:

   @Bean(name = "myUserDetailsService")
   // any or no name specified is allowed
   @Override
   public UserDetailsService userDetailsServiceBean() throws Exception {
    return super.userDetailsServiceBean();
   }

To change the instance returned, developers should change userDetailsService() instead

@diegobmd If you could put together a minimal sample with steps to reproduce I'll have a look at this.

@jgrandja the thing with having a custom authentication provider where you need to authenticate using both username and password (which is the case for many production systems out there e.g. when authenticating against an external system) is when it comes to refresh the token, you cannot simply load a user by username. So, which are the best practices for such cases? It would be really helpful to provide the options and the trade-offs. What means to attempt to refresh a token in such situations? One approach could be when you first authenticate a user and obtain the access token, to save a local copy of the user information to a DB for example and use this when refreshing a token. Of course if in the meantime something has changed (e.g. the user is deactivated or has changed password in the external system) such changes will not be reflected when refreshing token.

@kmandalas I agree that a custom AuthenticationProvider is a common use case. I do believe there is a way to do this today. It's just a matter of custom configuration. I can see what can be done with the existing codebase today. But I will need a sample that reproduces the issue so I can work out the right solution. Feel free to provide a minimal sample with reproducible steps and I'll take a look.

@jgrandja ok, I will try to create a minimal sample on GitHub and post the link here

@jgrandja minimal sample uploaded at: https://github.com/kmandalas/spring-security-demo

Hi, i have the same error, the problem start on "UserDetailsServiceDelegator" when is created the "defaultUserDetailsService" that are on "delegateBuilders" are null. is doesnt care if you set it later on, it doesnt take changes. So to fixed for now, i see this post. In my personal case i solved setting the "userDetailsService" on "AuthorizationServerEndpointsConfigurer". I check it the source publish by @kmandalas and have the same soluction, however in his case he does implement the "loadUserByUsername" on "MyUserDetailsServiceImpl".

So please @jgrandja could you check why is not detecting the changes of "defaultUserDetailsService", i believe the problem is the early creation on that class.

@nekperu15739 hello. Just to make clear that in my case/sample refresh token does not work. It throws error IllegalStateException, UserDetailsService is required. In my case where I authenticate against an external service, the demand to have a UserDetailsService and loadUserByUsername during refresh token is ambiguous and I report this issue in order to receive feedback what is the best practice in such cases. When we refresh a token does this mean attempt to re-authenticate the user? Or to simply "copy" previous token claims and generate a new access token? What if something has changed in the meantime, for example some user privilege?

Moreover I would like to know how such case should be approached taking into consideration the upcoming changes described in: https://spring.io/blog/2019/11/14/spring-security-oauth-2-0-roadmap-update

@kmandalas I took a look at your sample and the main issue why MyAuthenticationProvider is not being called for the refresh_token grant is because it does not support PreAuthenticatedAuthenticationToken.

If you look at DefaultTokenServices.refreshAccessToken(), you will see that it creates a PreAuthenticatedAuthenticationToken and then passes it to the AuthenticationManager. Given that MyAuthenticationProvider only supports UsernamePasswordAuthenticationToken (via AbstractUserDetailsAuthenticationProvider) it does not get called.

I'm attaching a git patch that you can apply and see what changes I made. The changes I made is a workaround. It will call MyAuthenticationProvider but it will still fail on a later check with missing credentials. There are some other changes I applied to AuthorizationServerConfiguration to get things working. This will at least move you ahead but there is some cleanup required.

spring-security-demo-patch.txt

@kmandalas you don't precise the userDetailsService for endpoints in your * AuthorizationServerConfiguration*.
You must do that
@Autowired
private YourUserDetailsService yourUserDetailsService;
...
...
endpoints.authenticationManager(authenticationManager).userDetailsService(yourUserDetailsService)...;

@akoua when we want to implement our CustomAuthenticationProvider, What is the need to provide a userdetailsservice. In my case, I dont want to validate the user with the password but authenticate with a 2fa token. I can't user userdetailsservice when I dont have any password right. Any suggestion would be helpful.

@jgrandja I tried to put the PreAuthenticatedAuthenticationToken.class in the supports method. Still I don't see it working. Could you please let me know if there is any mistake.

@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        Object details = authentication.getDetails();
        Map<String, String> detailsMap = null;
        if(details instanceof Map){
            detailsMap = (Map)details;
        }
        String username = detailsMap.get("username");
        // get some more details and authenticate the user here
        UserDetails user = new UserImpl(username, otherDetailsButNotPassword);
        return new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.equals(UsernamePasswordAuthenticationToken.class) || authentication.equals(PreAuthenticatedAuthenticationToken.class);
    }
}

If you would like us to look at this issue, please provide the requested information. If the information is not provided within the next 7 days this issue will be closed.

Closing due to lack of requested feedback. If you would like us to look at this issue, please provide the requested information and we will re-open the issue.

Was this page helpful?
0 / 5 - 0 ratings