-
Notifications
You must be signed in to change notification settings - Fork 6.2k
Description
Some background on what I am doing:
I have a SpringBoot (1.3.3.RELEASE) application with SpringSecurity and I am trying to add two factor authentication. Therefore I extended the DaoAuthenticationProvider with my TfaAuthenticationProvider, which overrides the additionalAuthenticationChecksmethod. This one checks the 2FA token and throws an AuthenticationException if something goes wrong.
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
super.additionalAuthenticationChecks(userDetails, authentication);
if (authentication.getDetails() instanceof TfaWebAuthenticationDetails) {
User user = (User) userDetails;
if (user.isTfaEnabled()) {
String tfaSecret = user.getTfaSecret();
Integer tfaKey = ((TfaWebAuthenticationDetails) authentication.getDetails()).getTfaKey();
if (tfaKey != null) {
try {
if (!tfaAuthenticator.verifyCode(tfaSecret, tfaKey, 2)) {
System.out.printf("Code %d was not valid", tfaKey);
throw new BadCredentialsException("Invalid 2FA code");
}
} catch (InvalidKeyException | NoSuchAlgorithmException e) {
throw new InternalAuthenticationServiceException("2FA code verification failed", e);
}
} else {
throw new MissingTfaKeyAuthenticatorException("2FA code is mandatory");
}
}
}
}Now I add this in my SecurityConfig, for example like that:
@Override
protected void configure(HttpSecurity http) throws Exception {
// [...]
http.authenticationProvider(authenticationProvider);
}
@Bean
public AuthenticationProvider authenticationProvider() {
TfaAuthenticationProvider tfaAuthenticationProvider = new TfaAuthenticationProvider();
tfaAuthenticationProvider.setTfaAuthenticator(new TfaAuthenticator());
tfaAuthenticationProvider.setUserDetailsService(userDetailsService);
return tfaAuthenticationProvider;
}I was surprised to see me successfully logging in, even when entering a wrong or no 2FA-Code at all. Debugging and code reading gave me insights, which I think might actually be a bug, or something I totally got wrong.
At some point during the authentication process I run into the ProviderManager's method authenticate.
Debugging shows me that my TfaAuthenticationProvideris successfully registered in the List<AuthenticationProvider> providers which now contains:
[0] TfaAuthenticationProvider
[1] AnonymousAuthenticationProvider
[2] DaoAuthenticationProvider
The documentation of the authenticate method says for the case of multiple providers:
If more than one
AuthenticationProvidersupports the passedAuthentication
object, only the firstAuthenticationProvidertried will determine the result. No subsequent
AuthenticationProviders will be tried.
Looking at the implementation this is only true for the positive case, i.e., if the first provider (in this case the TfaAuthenticationProvider already gives a result the code runs into
if (result != null) {
if (eraseCredentialsAfterAuthentication
&& (result instanceof CredentialsContainer)) {
// Authentication is complete. Remove credentials and other secret data
// from authentication
((CredentialsContainer) result).eraseCredentials();
}
eventPublisher.publishAuthenticationSuccess(result);
return result;
}but here's what I think is wrong. If the TfaAuthenticationProvider throws an AuthenticationException, for example because no 2FA code is provided, then the ProviderManager catches the Exception and stores it in lastException:
for (AuthenticationProvider provider : getProviders()) {
if (!provider.supports(toTest)) {
continue;
}
// [...]
try {
result = provider.authenticate(authentication);
if (result != null) {
copyDetails(authentication, result);
break;
}
}
// [...]
catch (AuthenticationException e) {
lastException = e;
}
}after that the for-loop continues and, the second provider (AnonymousAuthenticationProvider) says no support, continue. Then the DaoAuthenticationProvider successfully fills the result. After that the for-loop is exited (break) and the following code continues with both, a populated result and a populated lastException. Unfortunately the result is asked for first, the method then happily returns, skipping the part that would ask for the lastException:
if (result != null) {
if (eraseCredentialsAfterAuthentication
&& (result instanceof CredentialsContainer)) {
// Authentication is complete. Remove credentials and other secret data
// from authentication
((CredentialsContainer) result).eraseCredentials();
}
eventPublisher.publishAuthenticationSuccess(result);
return result;
}
// Parent was null, or didn't authenticate (or throw an exception).
if (lastException == null) {
lastException = new ProviderNotFoundException(messages.getMessage(
"ProviderManager.providerNotFound",
new Object[] { toTest.getName() },
"No AuthenticationProvider found for {0}"));
}
prepareException(lastException, authentication);
throw lastException;So here's three things. Either
i) I need to manage to replace the DaoAuthenticationProvider instead of adding my extended one
ii) catch (AuthenticationException e) { lastException = e; } should also break;, but the code and variable naming smells like there was a reason to store the "lastException" and to not break, or
iii) if(result != null) should also check for && lastException == null
It feels like from my custom Provider I have just no chance of saying "nope, you shall not pass". Or did I just get something wrong...?