Search code examples
springspring-bootauthenticationspring-security

Multiple Authentication Providers: do not delegate if authentication fails


I am trying to configure multiple authentication providers(Primary and Secondary) within an Spring authentication server (Spring Security < 5.0). I know that a user will be found either on the primary provider or the second, never both. So I would like to give a proper message if the authentication fail on the primary provider.

According to the "authenticate" method documentation:

Returns: a fully authenticated object including credentials. May return null if the AuthenticationProvider is unable to support authentication of the passed Authentication object. In such a case, the next AuthenticationProvider that supports the presented Authentication class will be tried.

Throws: AuthenticationException - if authentication fails.

Based on that I implemented the authenticate method on the primary provider as follows (I am going to omit the SecondaryAuthProvider implementation):

//PrimaryAuthProvider.class
public Authentication authenticate(Authentication authentication) {
    var user = authServices.getLdapUser(authentication.getName());

    //log and let the next provider handle it
    if (user == null) {
        logServices.userNotFound(new LogServices.AuthFailure(authentication.getName()));             
        return null;
    }

    if (passwordMatches(authentication.getCredentials(), user.getStringPassword())) {
        return authenticatedToken(user);
    } else {
        logServices.authFailure(new LogServices.AuthFailure(authentication.getName()));
        throw new BadCredentialsException("Invalid password");
    }
}

Inside WebSecurity I also inject my providers:

protected void configure(AuthenticationManagerBuilder auth) {
    auth.authenticationProvider(primaryAuthProvider);
    auth.authenticationProvider(secondaryAuthProvider);
}

This will handle things correctly if:

  • User informed correct login/password, no matter the provider.
  • User can't be found on primary provider, no matter if password is correct or not.

If the user is found on the primary provider and his password is wrong, BadCredentialsException will be thrown BUT the server will still delegate to secondary provider and there, the final message will be "User not found" which is misleading.

I supposed that the BadCredentialsException would finish the authentication chain and report back to the client/user but it does not seem to be the case.

Am I missing something?


Solution

  • Ok, just figured it out.

    Provider Manager Is the default Authentication Manager used on my server. It's authentication method indeed delegates to the next provider if a AuthenticationException occurs:

    for (AuthenticationProvider provider : getProviders()) {
        if (!provider.supports(toTest)) {
            continue;
        }
    
        //(...)
    
        try {
            result = provider.authenticate(authentication);
    
            if (result != null) {
                copyDetails(authentication, result);
                break;
            }
        }
        catch (AccountStatusException | InternalAuthenticationServiceException e) {
            prepareException(e, authentication);
            // SEC-546: Avoid polling additional providers if auth failure is due to
            // invalid account status
            throw e;
        } catch (AuthenticationException e) {
            lastException = e;
        }
    }
    

    I found two approaches to this.

    First one: provide an unauthenticated token on the primary provider if authentication fails and do not throw any exception:

    //PrimaryAuthProvider.class
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        var user = authServices.getLdapUser(authentication.getName());
    
        if (user == null) return null;
    
        if (passwordMatches(authentication.getCredentials(), user.getStringPassword())) {
            return authenticatedToken(user);
        } else {        
            return unauthenticatedToken(user);
        }
    }
    
    private UsernamePasswordAuthenticationToken unauthenticatedToken(LdapUser user)   {
            //Using 2 parameter constructor => authenticated = false 
            return new UsernamePasswordAuthenticationToken(
                user.getLogin(),
                user.getStringPassword(),
            );
    }
    

    This has the drawback of showing a default message in english. I would need to intercept the exception somewhere else and throw a new one in portuguese.

    Second one (wish I used): Implement my own AuthorizationManager as a smaller version of ProviderManager. This one will not try to capture the exceptions launched by the Providers:

    public class CustomProviderManager implements AuthenticationManager {
        private final List<AuthenticationProvider> providers;
    
        public CustomProviderManager(AuthenticationProvider... providers) {
            this.providers = List.of(providers);
        }
    
        @Override
        public Authentication authenticate(Authentication authentication) throws AuthenticationException {
            for (var provider : providers) {
                if (!provider.supports(authentication.getClass())) continue;
    
                //let exceptions go through
                var result = provider.authenticate(authentication); 
                if (result != null) {
                    return result;
                }
            }
    
            throw new ProviderNotFoundException(
                "No provider for " + authentication.getName()
            );
        }
    }
    

    Then, at WebSecurityConfig:

    @Bean(BeanIds.AUTHENTICATION_MANAGER)
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return authenticationManager();
    }
    
    @Override
    protected AuthenticationManager authenticationManager() {
        return new CustomProviderManager(primaryAuthProvider, secondaryAuthProvider);
    }
    
    
    // Don't need it anymore
    //    @Override
    //    protected void configure(AuthenticationManagerBuilder auth) {
    //        auth.authenticationProvider(authenticationProvider);
    //        auth.authenticationProvider(secondaryAuthProvider);
    //    }
    

    This second one needs a little bit more coding but gives me more control.