Issue
Complete code for a Spring OAuth2 implementation of multi-factor authentication has been uploaded to a file sharing site that you can download by clicking on this link. Instructions below explain how to use the link to recreate the current problem on any computer.
THE CURRENT ERROR:
An error is being triggered when a user tries to authenticate using two factor authentication in the Spring Boot OAuth2 app from the link in the preceding paragraph. The error is thrown at the point in the process when the app should serve up a second page asking the user for a pin code to confirm the user's identity.
Given that a null client is triggering this error, the problem seems to be how to connect a ClientDetailsService
to a Custom OAuth2RequestFactory
in Spring Boot OAuth2.
The entire debug log can be read at a file sharing site by clicking on this link. The complete stack trace in the logs contains only one reference to code that is actually in the app, and that line of code is:
AuthorizationRequest authorizationRequest =
oAuth2RequestFactory.createAuthorizationRequest(paramsFromRequest(request));
The error thrown in the debug logs is:
org.springframework.security.oauth2.provider.NoSuchClientException:
No client with requested id: null
CONTROL FLOW WHEN ERROR IS THROWN:
I created the following flowchart to illustrate the intended flow of multi-factor authentication requests in @James' suggested implementation:
In the preceding flowchart, the current error is being thrown at some point between the Username & Password View and the GET /secure/two_factor_authenticated steps.
The solution to this OP is limited in scope to the FIRST PASS that 1.) travels through the /oauth/authorize
endpoint and then 2.) returns back to the /oauth/authorize
endpoint via TwoFactorAuthenticationController
.
So we simply want to resolve the NoSuchClientException
while also demonstrating that the client has been successfully granted ROLE_TWO_FACTOR_AUTHENTICATED
in the POST /secure/two_factor_authenticated
. Given that the subsequent steps are boiler-plate, it is acceptable for the flow to demonstrably break in the SECOND PASS entry into CustomOAuth2RequestFactory
, as long as the user enters the SECOND PASS with all the artifacts of successfully having completed the FIRST PASS. The SECOND PASS can be a separate question as long as we successfully resolve the FIRST PASS here.
RELEVANT CODE EXCERPTS:
Here is the code for the AuthorizationServerConfigurerAdapter
, where I attempt to set up the connection:
@Configuration
@EnableAuthorizationServer
protected static class OAuth2AuthorizationConfig extends AuthorizationServerConfigurerAdapter {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired//ADDED AS A TEST TO TRY TO HOOK UP THE CUSTOM REQUEST FACTORY
private ClientDetailsService clientDetailsService;
@Autowired//Added per: https://stackoverflow.com/questions/30319666/two-factor-authentication-with-spring-security-oauth2
private CustomOAuth2RequestFactory customOAuth2RequestFactory;
//THIS NEXT BEAN IS A TEST
@Bean CustomOAuth2RequestFactory customOAuth2RequestFactory(){
return new CustomOAuth2RequestFactory(clientDetailsService);
}
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
KeyPair keyPair = new KeyStoreKeyFactory(
new ClassPathResource("keystore.jks"), "foobar".toCharArray()
)
.getKeyPair("test");
converter.setKeyPair(keyPair);
return converter;
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("acme")//API: http://docs.spring.io/spring-security/oauth/apidocs/org/springframework/security/oauth2/config/annotation/builders/ClientDetailsServiceBuilder.ClientBuilder.html
.secret("acmesecret")
.authorizedGrantTypes("authorization_code", "refresh_token", "password")
.scopes("openid");
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints//API: http://docs.spring.io/spring-security/oauth/apidocs/org/springframework/security/oauth2/config/annotation/web/configurers/AuthorizationServerEndpointsConfigurer.html
.authenticationManager(authenticationManager)
.accessTokenConverter(jwtAccessTokenConverter())
.requestFactory(customOAuth2RequestFactory);//Added per: https://stackoverflow.com/questions/30319666/two-factor-authentication-with-spring-security-oauth2
}
@Override
public void configure(AuthorizationServerSecurityConfigurer oauthServer) throws Exception {
oauthServer//API: http://docs.spring.io/spring-security/oauth/apidocs/org/springframework/security/oauth2/config/annotation/web/configurers/AuthorizationServerSecurityConfigurer.html
.tokenKeyAccess("permitAll()")
.checkTokenAccess("isAuthenticated()");
}
}
Here is the code for the TwoFactorAuthenticationFilter
, which contains the code above that is triggering the error:
package demo;
import java.io.IOException;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.provider.AuthorizationRequest;
import org.springframework.security.oauth2.provider.ClientDetailsService;
import org.springframework.security.oauth2.provider.OAuth2RequestFactory;
import org.springframework.security.oauth2.provider.request.DefaultOAuth2RequestFactory;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
//This class is added per: https://stackoverflow.com/questions/30319666/two-factor-authentication-with-spring-security-oauth2
/**
* Stores the oauth authorizationRequest in the session so that it can
* later be picked by the {@link com.example.CustomOAuth2RequestFactory}
* to continue with the authoriztion flow.
*/
public class TwoFactorAuthenticationFilter extends OncePerRequestFilter {
private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
private OAuth2RequestFactory oAuth2RequestFactory;
//These next two are added as a test to avoid the compilation errors that happened when they were not defined.
public static final String ROLE_TWO_FACTOR_AUTHENTICATED = "ROLE_TWO_FACTOR_AUTHENTICATED";
public static final String ROLE_TWO_FACTOR_AUTHENTICATION_ENABLED = "ROLE_TWO_FACTOR_AUTHENTICATION_ENABLED";
@Autowired
public void setClientDetailsService(ClientDetailsService clientDetailsService) {
oAuth2RequestFactory = new DefaultOAuth2RequestFactory(clientDetailsService);
}
private boolean twoFactorAuthenticationEnabled(Collection<? extends GrantedAuthority> authorities) {
return authorities.stream().anyMatch(
authority -> ROLE_TWO_FACTOR_AUTHENTICATION_ENABLED.equals(authority.getAuthority())
);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// Check if the user hasn't done the two factor authentication.
if (AuthenticationUtil.isAuthenticated() && !AuthenticationUtil.hasAuthority(ROLE_TWO_FACTOR_AUTHENTICATED)) {
AuthorizationRequest authorizationRequest = oAuth2RequestFactory.createAuthorizationRequest(paramsFromRequest(request));
/* Check if the client's authorities (authorizationRequest.getAuthorities()) or the user's ones
require two factor authenticatoin. */
if (twoFactorAuthenticationEnabled(authorizationRequest.getAuthorities()) ||
twoFactorAuthenticationEnabled(SecurityContextHolder.getContext().getAuthentication().getAuthorities())) {
// Save the authorizationRequest in the session. This allows the CustomOAuth2RequestFactory
// to return this saved request to the AuthenticationEndpoint after the user successfully
// did the two factor authentication.
request.getSession().setAttribute(CustomOAuth2RequestFactory.SAVED_AUTHORIZATION_REQUEST_SESSION_ATTRIBUTE_NAME, authorizationRequest);
// redirect the the page where the user needs to enter the two factor authentiation code
redirectStrategy.sendRedirect(request, response,
ServletUriComponentsBuilder.fromCurrentContextPath()
.path(TwoFactorAuthenticationController.PATH)
.toUriString());
return;
}
}
filterChain.doFilter(request, response);
}
private Map<String, String> paramsFromRequest(HttpServletRequest request) {
Map<String, String> params = new HashMap<>();
for (Entry<String, String[]> entry : request.getParameterMap().entrySet()) {
params.put(entry.getKey(), entry.getValue()[0]);
}
return params;
}
}
RE-CREATING THE PROBLEM ON YOUR COMPUTER:
You can recreate the problem on any computer in only a few minutes by following these simple steps:
1.) Download the zipped version of the app from a file sharing site by clicking on this link.
2.) Unzip the app by typing: tar -zxvf oauth2.tar(1).gz
3.) launch the authserver
app by navigating to oauth2/authserver
and then typing mvn spring-boot:run
.
4.) launch the resource
app by navigating to oauth2/resource
and then typing mvn spring-boot:run
5.) launch the ui
app by navigating to oauth2/ui
and then typing mvn spring-boot:run
6.) Open a web browser and navigate to http : // localhost : 8080
7.) Click Login
and then enter Frodo
as the user and MyRing
as the password, and click to submit. This will trigger the error shown above.
You can view the complete source code by:
a.) importing the maven projects into your IDE, or by
b.) navigating within the unzipped directories and opening with a text editor.
Note: The code in the file sharing link above is a combination of the Spring Boot OAuth2 GitHub sample at this link, and the suggestions for 2 Factor Authentication offered by @James at this link. The only changes to the Spring Boot GitHub sample have been in the authserver
app, specifically in authserver/src/main/java
and in authserver/src/main/resources/templates
.
NARROWING THE PROBLEM:
Per @AbrahamGrief's suggestion, I added a FilterConfigurationBean
, which resolved the NoSuchClientException
. But the OP asks how to complete the FIRST PASS through the control flow in the diagram.
I then narrowed the problem by setting ROLE_TWO_FACTOR_AUTHENTICATION_ENABLED
in Users.loadUserByUername()
as follows:
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
String password;
List<GrantedAuthority> auth = AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_USER");
if (username.equals("Samwise")) {//ROLE_TWO_FACTOR_AUTHENTICATION_ENABLED will need to come from the resource, NOT the user
auth = AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_HOBBIT, ROLE_TWO_FACTOR_AUTHENTICATION_ENABLED");
password = "TheShire";
}
else if (username.equals("Frodo")){//ROLE_TWO_FACTOR_AUTHENTICATION_ENABLED will need to come from the resource, NOT the user
auth = AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_HOBBIT, ROLE_TWO_FACTOR_AUTHENTICATION_ENABLED");
password = "MyRing";
}
else{throw new UsernameNotFoundException("Username was not found. ");}
return new org.springframework.security.core.userdetails.User(username, password, auth);
}
This eliminates the need to configure clients and resources, so that the current problem remains narrow. However, the next roadblock is that Spring Security is rejecting the user's request for /security/two_factor_authentication
. What further changes need to be made to complete the FIRST PASS through the control flow, so that the POST /secure/two_factor_authentication
can SYSO ROLE_TWO_FACTOR_AUTHENTICATED
?
Solution
There are a lot of modifications needed for that project to implement the described flow, more than should be in scope for a single question. This answer will focus solely on how to resolve:
org.springframework.security.oauth2.provider.NoSuchClientException: No client with requested id: null
when trying to use a SecurityWebApplicationInitializer
and a Filter
bean while running in a Spring Boot authorization server.
The reason this exception is happening is because WebApplicationInitializer
instances are not run by Spring Boot. That includes any AbstractSecurityWebApplicationInitializer
subclasses that would work in a WAR deployed to a standalone Servlet container. So what is happening is Spring Boot creates your filter because of the @Bean
annotation, ignores your AbstractSecurityWebApplicationInitializer
, and applies your filter to all URLs. Meanwhile, you only want your filter applied to those URLs that you're trying to pass to addMappingForUrlPatterns
.
Instead, to apply a servlet Filter to particular URLs in Spring Boot, you should define a FilterConfigurationBean
. For the flow described in the question, which is trying to apply a custom TwoFactorAuthenticationFilter
to /oauth/authorize
, that would look as follows:
@Bean
public FilterRegistrationBean twoFactorAuthenticationFilterRegistration() {
FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setFilter(twoFactorAuthenticationFilter());
registration.addUrlPatterns("/oauth/authorize");
registration.setName("twoFactorAuthenticationFilter");
return registration;
}
@Bean
public TwoFactorAuthenticationFilter twoFactorAuthenticationFilter() {
return new TwoFactorAuthenticationFilter();
}
Answered By - heenenee
Answer Checked By - Cary Denson (JavaFixing Admin)