I'm looking for ideas how to implement two factor authentication (2FA) with spring security OAuth2. The requirement is that the user needs two factor authentication only for specific applications with sensitive information. Those webapps have their own client ids.
One idea that popped in my mind would be to "mis-use" the scope approval page to force the user to enter the 2FA code/PIN (or whatever).
Sample flows would look like this:
Accessing apps without and with 2FA
Directly accessing app with 2FA
Do you have other ideas how to apporach this?
OAuth2 is for "Server Site Authorization" of certain parameter(s) access (designated by Server site) given to a requesting entity (or App). Whereas 2FA is about Authenticating an Account Owner entity logging into an Account on the Server Site (with full owner access).
JWT and Social Authentication using Spring Boot Two-factor authentication is a way to increase the security of your product by adding an extra layer of security by asking the user to provide a second form of identification alongside username and password.
The user will first authenticate using a username and password. If the account has 2FA enabled, we'll receive a JWT with a property marked as not authenticated. If we try to access a protected endpoint while not authenticated, we will get an error.
So this is how two factor authentication has been implemented finally:
A filter is registered for the /oauth/authorize path after the spring security filter:
@Order(200) public class SecurityWebApplicationInitializer extends AbstractSecurityWebApplicationInitializer { @Override protected void afterSpringSecurityFilterChain(ServletContext servletContext) { FilterRegistration.Dynamic twoFactorAuthenticationFilter = servletContext.addFilter("twoFactorAuthenticationFilter", new DelegatingFilterProxy(AppConfig.TWO_FACTOR_AUTHENTICATION_BEAN)); twoFactorAuthenticationFilter.addMappingForUrlPatterns(null, false, "/oauth/authorize"); super.afterSpringSecurityFilterChain(servletContext); } }
This filter checks if the user hasn't already authenticated with a 2nd factor (by checking if the ROLE_TWO_FACTOR_AUTHENTICATED
authority isn't available) and creates an OAuth AuthorizationRequest
which is put into the session. The user is then redirected to the page where he has to enter the 2FA code:
/** * 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; @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; } else { request.getSession().removeAttribute(CustomOAuth2RequestFactory.SAVED_AUTHORIZATION_REQUEST_SESSION_ATTRIBUTE_NAME); } } 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; } }
The TwoFactorAuthenticationController
that handles entering the 2FA-code adds the authority ROLE_TWO_FACTOR_AUTHENTICATED
if the code was correct and redirects the user back to the /oauth/authorize endpoint.
@Controller @RequestMapping(TwoFactorAuthenticationController.PATH) public class TwoFactorAuthenticationController { private static final Logger LOG = LoggerFactory.getLogger(TwoFactorAuthenticationController.class); public static final String PATH = "/secure/two_factor_authentication"; @RequestMapping(method = RequestMethod.GET) public String auth(HttpServletRequest request, HttpSession session, ....) { if (AuthenticationUtil.isAuthenticatedWithAuthority(ROLE_TWO_FACTOR_AUTHENTICATED)) { LOG.info("User {} already has {} authority - no need to enter code again", ROLE_TWO_FACTOR_AUTHENTICATED); throw ....; } else if (session.getAttribute(CustomOAuth2RequestFactory.SAVED_AUTHORIZATION_REQUEST_SESSION_ATTRIBUTE_NAME) == null) { LOG.warn("Error while entering 2FA code - attribute {} not found in session.", CustomOAuth2RequestFactory.SAVED_AUTHORIZATION_REQUEST_SESSION_ATTRIBUTE_NAME); throw ....; } return ....; // Show the form to enter the 2FA secret } @RequestMapping(method = RequestMethod.POST) public String auth(....) { if (userEnteredCorrect2FASecret()) { AuthenticationUtil.addAuthority(ROLE_TWO_FACTOR_AUTHENTICATED); return "forward:/oauth/authorize"; // Continue with the OAuth flow } return ....; // Show the form to enter the 2FA secret again } }
A custom OAuth2RequestFactory
retrieves the previously saved AuthorizationRequest
from the session if available and returns that or creates a new one if none can be found in the session.
/** * If the session contains an {@link AuthorizationRequest}, this one is used and returned. * The {@link com.example.TwoFactorAuthenticationFilter} saved the original AuthorizationRequest. This allows * to redirect the user away from the /oauth/authorize endpoint during oauth authorization * and show him e.g. a the page where he has to enter a code for two factor authentication. * Redirecting him back to /oauth/authorize will use the original authorizationRequest from the session * and continue with the oauth authorization. */ public class CustomOAuth2RequestFactory extends DefaultOAuth2RequestFactory { public static final String SAVED_AUTHORIZATION_REQUEST_SESSION_ATTRIBUTE_NAME = "savedAuthorizationRequest"; public CustomOAuth2RequestFactory(ClientDetailsService clientDetailsService) { super(clientDetailsService); } @Override public AuthorizationRequest createAuthorizationRequest(Map<String, String> authorizationParameters) { ServletRequestAttributes attr = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes(); HttpSession session = attr.getRequest().getSession(false); if (session != null) { AuthorizationRequest authorizationRequest = (AuthorizationRequest) session.getAttribute(SAVED_AUTHORIZATION_REQUEST_SESSION_ATTRIBUTE_NAME); if (authorizationRequest != null) { session.removeAttribute(SAVED_AUTHORIZATION_REQUEST_SESSION_ATTRIBUTE_NAME); return authorizationRequest; } } return super.createAuthorizationRequest(authorizationParameters); } }
This custom OAuth2RequestFactory is set to the authorization server like:
<bean id="customOAuth2RequestFactory" class="com.example.CustomOAuth2RequestFactory"> <constructor-arg index="0" ref="clientDetailsService" /> </bean> <!-- Configures the authorization-server and provides the /oauth/authorize endpoint --> <oauth:authorization-server client-details-service-ref="clientDetailsService" token-services-ref="tokenServices" user-approval-handler-ref="approvalStoreUserApprovalHandler" redirect-resolver-ref="redirectResolver" authorization-request-manager-ref="customOAuth2RequestFactory"> <oauth:authorization-code authorization-code-services-ref="authorizationCodeServices"/> <oauth:implicit /> <oauth:refresh-token /> <oauth:client-credentials /> <oauth:password /> </oauth:authorization-server>
When using java config you can create a TwoFactorAuthenticationInterceptor
instead of the TwoFactorAuthenticationFilter
and register it with an AuthorizationServerConfigurer
with
@Configuration @EnableAuthorizationServer public class AuthorizationServerConfig implements AuthorizationServerConfigurer { ... @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { endpoints .addInterceptor(twoFactorAuthenticationInterceptor()) ... .requestFactory(customOAuth2RequestFactory()); } @Bean public HandlerInterceptor twoFactorAuthenticationInterceptor() { return new TwoFactorAuthenticationInterceptor(); } }
The TwoFactorAuthenticationInterceptor
contains the same logic as the TwoFactorAuthenticationFilter
in its preHandle
method.
I couldn't make the accepted solution work. I have been working on this for a while, and finally I wrote my solution by using the ideas explained here and on this thread "null client in OAuth2 Multi-Factor Authentication"
Here is the GitHub location for the working solution for me: https://github.com/turgos/oauth2-2FA
I appreciate if you share your feedback in case you see any issues or better approach.
Below you can find the key configuration files for this solution.
AuthorizationServerConfig
@Configuration @EnableAuthorizationServer public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { @Autowired private AuthenticationManager authenticationManager; @Autowired private ClientDetailsService clientDetailsService; @Override public void configure(AuthorizationServerSecurityConfigurer security) throws Exception { security.tokenKeyAccess("permitAll()") .checkTokenAccess("isAuthenticated()"); } @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients .inMemory() .withClient("ClientId") .secret("secret") .authorizedGrantTypes("authorization_code") .scopes("user_info") .authorities(TwoFactorAuthenticationFilter.ROLE_TWO_FACTOR_AUTHENTICATION_ENABLED) .autoApprove(true); } @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { endpoints .authenticationManager(authenticationManager) .requestFactory(customOAuth2RequestFactory()); } @Bean public DefaultOAuth2RequestFactory customOAuth2RequestFactory(){ return new CustomOAuth2RequestFactory(clientDetailsService); } @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(); } }
CustomOAuth2RequestFactory
public class CustomOAuth2RequestFactory extends DefaultOAuth2RequestFactory { private static final Logger LOG = LoggerFactory.getLogger(CustomOAuth2RequestFactory.class); public static final String SAVED_AUTHORIZATION_REQUEST_SESSION_ATTRIBUTE_NAME = "savedAuthorizationRequest"; public CustomOAuth2RequestFactory(ClientDetailsService clientDetailsService) { super(clientDetailsService); } @Override public AuthorizationRequest createAuthorizationRequest(Map<String, String> authorizationParameters) { ServletRequestAttributes attr = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes(); HttpSession session = attr.getRequest().getSession(false); if (session != null) { AuthorizationRequest authorizationRequest = (AuthorizationRequest) session.getAttribute(SAVED_AUTHORIZATION_REQUEST_SESSION_ATTRIBUTE_NAME); if (authorizationRequest != null) { session.removeAttribute(SAVED_AUTHORIZATION_REQUEST_SESSION_ATTRIBUTE_NAME); LOG.debug("createAuthorizationRequest(): return saved copy."); return authorizationRequest; } } LOG.debug("createAuthorizationRequest(): create"); return super.createAuthorizationRequest(authorizationParameters); } }
WebSecurityConfig
@EnableResourceServer @Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true) public class ResourceServerConfig extends WebSecurityConfigurerAdapter { @Autowired CustomDetailsService customDetailsService; @Bean public PasswordEncoder encoder() { return new BCryptPasswordEncoder(); } @Bean(name = "authenticationManager") @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Override public void configure(WebSecurity web) throws Exception { web.ignoring().antMatchers("/webjars/**"); web.ignoring().antMatchers("/css/**","/fonts/**","/libs/**"); } @Override protected void configure(HttpSecurity http) throws Exception { // @formatter:off http.requestMatchers() .antMatchers("/login", "/oauth/authorize", "/secure/two_factor_authentication","/exit", "/resources/**") .and() .authorizeRequests() .anyRequest() .authenticated() .and() .formLogin().loginPage("/login") .permitAll(); } // @formatter:on @Override @Autowired // <-- This is crucial otherwise Spring Boot creates its own protected void configure(AuthenticationManagerBuilder auth) throws Exception { // auth//.parentAuthenticationManager(authenticationManager) // .inMemoryAuthentication() // .withUser("demo") // .password("demo") // .roles("USER"); auth.userDetailsService(customDetailsService).passwordEncoder(encoder()); } }
TwoFactorAuthenticationFilter
public class TwoFactorAuthenticationFilter extends OncePerRequestFilter { private static final Logger LOG = LoggerFactory.getLogger(TwoFactorAuthenticationFilter.class); 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()) ); } 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; } @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 (isAuthenticated() && !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 authentication. */ 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); LOG.debug("doFilterInternal(): redirecting to {}", TwoFactorAuthenticationController.PATH); // redirect the the page where the user needs to enter the two factor authentication code redirectStrategy.sendRedirect(request, response, TwoFactorAuthenticationController.PATH ); return; } } LOG.debug("doFilterInternal(): without redirect."); filterChain.doFilter(request, response); } public boolean isAuthenticated(){ return SecurityContextHolder.getContext().getAuthentication().isAuthenticated(); } private boolean hasAuthority(String checkedAuthority){ return SecurityContextHolder.getContext().getAuthentication().getAuthorities().stream().anyMatch( authority -> checkedAuthority.equals(authority.getAuthority()) ); } }
TwoFactorAuthenticationController
@Controller @RequestMapping(TwoFactorAuthenticationController.PATH) public class TwoFactorAuthenticationController { private static final Logger LOG = LoggerFactory.getLogger(TwoFactorAuthenticationController.class); public static final String PATH = "/secure/two_factor_authentication"; @RequestMapping(method = RequestMethod.GET) public String auth(HttpServletRequest request, HttpSession session) { if (isAuthenticatedWithAuthority(TwoFactorAuthenticationFilter.ROLE_TWO_FACTOR_AUTHENTICATED)) { LOG.debug("User {} already has {} authority - no need to enter code again", TwoFactorAuthenticationFilter.ROLE_TWO_FACTOR_AUTHENTICATED); //throw ....; } else if (session.getAttribute(CustomOAuth2RequestFactory.SAVED_AUTHORIZATION_REQUEST_SESSION_ATTRIBUTE_NAME) == null) { LOG.debug("Error while entering 2FA code - attribute {} not found in session.", CustomOAuth2RequestFactory.SAVED_AUTHORIZATION_REQUEST_SESSION_ATTRIBUTE_NAME); //throw ....; } LOG.debug("auth() HTML.Get"); return "loginSecret"; // Show the form to enter the 2FA secret } @RequestMapping(method = RequestMethod.POST) public String auth(@ModelAttribute(value="secret") String secret, BindingResult result, Model model) { LOG.debug("auth() HTML.Post"); if (userEnteredCorrect2FASecret(secret)) { addAuthority(TwoFactorAuthenticationFilter.ROLE_TWO_FACTOR_AUTHENTICATED); return "forward:/oauth/authorize"; // Continue with the OAuth flow } model.addAttribute("isIncorrectSecret", true); return "loginSecret"; // Show the form to enter the 2FA secret again } private boolean isAuthenticatedWithAuthority(String checkedAuthority){ return SecurityContextHolder.getContext().getAuthentication().getAuthorities().stream().anyMatch( authority -> checkedAuthority.equals(authority.getAuthority()) ); } private boolean addAuthority(String authority){ Collection<SimpleGrantedAuthority> oldAuthorities = (Collection<SimpleGrantedAuthority>)SecurityContextHolder.getContext().getAuthentication().getAuthorities(); SimpleGrantedAuthority newAuthority = new SimpleGrantedAuthority(authority); List<SimpleGrantedAuthority> updatedAuthorities = new ArrayList<SimpleGrantedAuthority>(); updatedAuthorities.add(newAuthority); updatedAuthorities.addAll(oldAuthorities); SecurityContextHolder.getContext().setAuthentication( new UsernamePasswordAuthenticationToken( SecurityContextHolder.getContext().getAuthentication().getPrincipal(), SecurityContextHolder.getContext().getAuthentication().getCredentials(), updatedAuthorities) ); return true; } private boolean userEnteredCorrect2FASecret(String secret){ /* later on, we need to pass a temporary secret for each user and control it here */ /* this is just a temporary way to check things are working */ if(secret.equals("123")) return true; else; return false; } }
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With