SEC-2056: DaoAuthenticationProvider performs isPasswordValid when user not found

Previously authenticating a user could take significantly longer than
determining that a user does not exist. This was due to the fact that only
users that were found would use the password encoder and comparing a
password can take a significant amount of time. The difference in the
time required could allow a side channel attack that reveals if a user
exists.

The code has been updated to do comparison against a dummy password
even when the the user was not found.
This commit is contained in:
Rob Winch 2012-09-21 14:02:13 -05:00
parent f3b143f677
commit c076f0f2e1
2 changed files with 154 additions and 4 deletions

View File

@ -31,19 +31,39 @@ import org.springframework.util.Assert;
* An {@link AuthenticationProvider} implementation that retrieves user details from a {@link UserDetailsService}. * An {@link AuthenticationProvider} implementation that retrieves user details from a {@link UserDetailsService}.
* *
* @author Ben Alex * @author Ben Alex
* @author Rob Winch
*/ */
public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider { public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
//~ Static fields/initializers =====================================================================================
/**
* The plaintext password used to perform {@link PasswordEncoder#isPasswordValid(String, String, Object)} on when the user is
* not found to avoid SEC-2056.
*/
private static final String USER_NOT_FOUND_PASSWORD = "userNotFoundPassword";
//~ Instance fields ================================================================================================ //~ Instance fields ================================================================================================
private PasswordEncoder passwordEncoder = new PlaintextPasswordEncoder(); private PasswordEncoder passwordEncoder;
/**
* The password used to perform {@link PasswordEncoder#isPasswordValid(String, String, Object)} on when the user is
* not found to avoid SEC-2056. This is necessary, because some {@link PasswordEncoder} implementations will short circuit if the
* password is not in a valid format.
*/
private String userNotFoundEncodedPassword;
private SaltSource saltSource; private SaltSource saltSource;
private UserDetailsService userDetailsService; private UserDetailsService userDetailsService;
public DaoAuthenticationProvider() {
setPasswordEncoder(new PlaintextPasswordEncoder());
}
//~ Methods ======================================================================================================== //~ Methods ========================================================================================================
@SuppressWarnings("deprecation")
protected void additionalAuthenticationChecks(UserDetails userDetails, protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
Object salt = null; Object salt = null;
@ -80,6 +100,10 @@ public class DaoAuthenticationProvider extends AbstractUserDetailsAuthentication
try { try {
loadedUser = this.getUserDetailsService().loadUserByUsername(username); loadedUser = this.getUserDetailsService().loadUserByUsername(username);
} catch (UsernameNotFoundException notFound) { } catch (UsernameNotFoundException notFound) {
if(authentication.getCredentials() != null) {
String presentedPassword = authentication.getCredentials().toString();
passwordEncoder.isPasswordValid(userNotFoundEncodedPassword, presentedPassword, null);
}
throw notFound; throw notFound;
} catch (Exception repositoryProblem) { } catch (Exception repositoryProblem) {
throw new AuthenticationServiceException(repositoryProblem.getMessage(), repositoryProblem); throw new AuthenticationServiceException(repositoryProblem.getMessage(), repositoryProblem);
@ -106,14 +130,14 @@ public class DaoAuthenticationProvider extends AbstractUserDetailsAuthentication
Assert.notNull(passwordEncoder, "passwordEncoder cannot be null"); Assert.notNull(passwordEncoder, "passwordEncoder cannot be null");
if (passwordEncoder instanceof PasswordEncoder) { if (passwordEncoder instanceof PasswordEncoder) {
this.passwordEncoder = (PasswordEncoder) passwordEncoder; setPasswordEncoder((PasswordEncoder) passwordEncoder);
return; return;
} }
if (passwordEncoder instanceof org.springframework.security.crypto.password.PasswordEncoder) { if (passwordEncoder instanceof org.springframework.security.crypto.password.PasswordEncoder) {
final org.springframework.security.crypto.password.PasswordEncoder delegate = final org.springframework.security.crypto.password.PasswordEncoder delegate =
(org.springframework.security.crypto.password.PasswordEncoder)passwordEncoder; (org.springframework.security.crypto.password.PasswordEncoder)passwordEncoder;
this.passwordEncoder = new PasswordEncoder() { setPasswordEncoder(new PasswordEncoder() {
public String encodePassword(String rawPass, Object salt) { public String encodePassword(String rawPass, Object salt) {
checkSalt(salt); checkSalt(salt);
return delegate.encode(rawPass); return delegate.encode(rawPass);
@ -127,7 +151,7 @@ public class DaoAuthenticationProvider extends AbstractUserDetailsAuthentication
private void checkSalt(Object salt) { private void checkSalt(Object salt) {
Assert.isNull(salt, "Salt value must be null when used with crypto module PasswordEncoder"); Assert.isNull(salt, "Salt value must be null when used with crypto module PasswordEncoder");
} }
}; });
return; return;
} }
@ -135,6 +159,13 @@ public class DaoAuthenticationProvider extends AbstractUserDetailsAuthentication
throw new IllegalArgumentException("passwordEncoder must be a PasswordEncoder instance"); throw new IllegalArgumentException("passwordEncoder must be a PasswordEncoder instance");
} }
private void setPasswordEncoder(PasswordEncoder passwordEncoder) {
Assert.notNull(passwordEncoder, "passwordEncoder cannot be null");
this.userNotFoundEncodedPassword = passwordEncoder.encodePassword(USER_NOT_FOUND_PASSWORD, null);
this.passwordEncoder = passwordEncoder;
}
protected PasswordEncoder getPasswordEncoder() { protected PasswordEncoder getPasswordEncoder() {
return passwordEncoder; return passwordEncoder;
} }

View File

@ -15,6 +15,15 @@
package org.springframework.security.authentication.dao; package org.springframework.security.authentication.dao;
import static org.mockito.Matchers.anyString;
import static org.mockito.Matchers.isA;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import junit.framework.TestCase; import junit.framework.TestCase;
@ -38,12 +47,15 @@ import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.core.userdetails.cache.EhCacheBasedUserCache; import org.springframework.security.core.userdetails.cache.EhCacheBasedUserCache;
import org.springframework.security.core.userdetails.cache.NullUserCache; import org.springframework.security.core.userdetails.cache.NullUserCache;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
/** /**
* Tests {@link DaoAuthenticationProvider}. * Tests {@link DaoAuthenticationProvider}.
* *
* @author Ben Alex * @author Ben Alex
* @author Rob Winch
*/ */
public class DaoAuthenticationProviderTests extends TestCase { public class DaoAuthenticationProviderTests extends TestCase {
private static final List<GrantedAuthority> ROLES_12 = AuthorityUtils.createAuthorityList("ROLE_ONE", "ROLE_TWO"); private static final List<GrantedAuthority> ROLES_12 = AuthorityUtils.createAuthorityList("ROLE_ONE", "ROLE_TWO");
@ -433,6 +445,113 @@ public class DaoAuthenticationProviderTests extends TestCase {
assertTrue(!provider.supports(TestingAuthenticationToken.class)); assertTrue(!provider.supports(TestingAuthenticationToken.class));
} }
// SEC-2056
public void testUserNotFoundEncodesPassword() {
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken("missing", "koala");
PasswordEncoder encoder = mock(PasswordEncoder.class);
when(encoder.encode(anyString())).thenReturn("koala");
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setHideUserNotFoundExceptions(false);
provider.setPasswordEncoder(encoder);
provider.setUserDetailsService(new MockAuthenticationDaoUserrod());
try {
provider.authenticate(token);
fail("Expected Exception");
} catch(UsernameNotFoundException success) {}
// ensure encoder invoked w/ non-null strings since PasswordEncoder impls may fail if encoded password is null
verify(encoder).matches(isA(String.class), isA(String.class));
}
public void testUserNotFoundBCryptPasswordEncoder() {
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken("missing", "koala");
PasswordEncoder encoder = new BCryptPasswordEncoder();
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setHideUserNotFoundExceptions(false);
provider.setPasswordEncoder(encoder);
MockAuthenticationDaoUserrod userDetailsService = new MockAuthenticationDaoUserrod();
userDetailsService.password = encoder.encode((CharSequence) token.getCredentials());
provider.setUserDetailsService(userDetailsService);
try {
provider.authenticate(token);
fail("Expected Exception");
} catch(UsernameNotFoundException success) {}
}
public void testUserNotFoundDefaultEncoder() {
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken("missing", null);
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setHideUserNotFoundExceptions(false);
provider.setUserDetailsService(new MockAuthenticationDaoUserrod());
try {
provider.authenticate(token);
fail("Expected Exception");
} catch(UsernameNotFoundException success) {}
}
/**
* This is an explicit test for SEC-2056. It is intentionally ignored since this test is not
* deterministic and {@link #testUserNotFoundEncodesPassword()} ensures that SEC-2056 is fixed.
*/
public void IGNOREtestSec2056() {
UsernamePasswordAuthenticationToken foundUser = new UsernamePasswordAuthenticationToken("rod", "koala");
UsernamePasswordAuthenticationToken notFoundUser = new UsernamePasswordAuthenticationToken("notFound", "koala");
PasswordEncoder encoder = new BCryptPasswordEncoder(10,new SecureRandom());
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setHideUserNotFoundExceptions(false);
provider.setPasswordEncoder(encoder);
MockAuthenticationDaoUserrod userDetailsService = new MockAuthenticationDaoUserrod();
userDetailsService.password = encoder.encode((CharSequence) foundUser.getCredentials());
provider.setUserDetailsService(userDetailsService);
int sampleSize = 100;
List<Long> userFoundTimes = new ArrayList<Long>(sampleSize);
for(int i=0;i<sampleSize;i++) {
long start = System.currentTimeMillis();
provider.authenticate(foundUser);
userFoundTimes.add(System.currentTimeMillis() - start);
}
List<Long> userNotFoundTimes = new ArrayList<Long>(sampleSize);
for(int i=0;i<sampleSize;i++) {
long start = System.currentTimeMillis();
try {
provider.authenticate(notFoundUser);
fail("Expected Exception");
} catch(UsernameNotFoundException success) {}
userNotFoundTimes.add(System.currentTimeMillis() - start);
}
double userFoundAvg = avg(userFoundTimes);
double userNotFoundAvg = avg(userNotFoundTimes);
assertTrue("User not found average " + userNotFoundAvg + " should be within 3ms of user found average " + userFoundAvg,
Math.abs(userNotFoundAvg - userFoundAvg) <= 3);
}
private double avg(List<Long> counts) {
long sum = 0;
for(Long time : counts) {
sum += time;
}
return sum / counts.size();
}
public void testUserNotFoundNullCredentials() {
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken("missing", null);
PasswordEncoder encoder = mock(PasswordEncoder.class);
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setHideUserNotFoundExceptions(false);
provider.setPasswordEncoder(encoder);
provider.setUserDetailsService(new MockAuthenticationDaoUserrod());
try {
provider.authenticate(token);
fail("Expected Exception");
} catch(UsernameNotFoundException success) {}
verify(encoder,times(0)).matches(anyString(), anyString());
}
//~ Inner Classes ================================================================================================== //~ Inner Classes ==================================================================================================
private class MockAuthenticationDaoReturnsNull implements UserDetailsService { private class MockAuthenticationDaoReturnsNull implements UserDetailsService {