Two factor authentication with spring security oauth2 Two factor authentication with spring security oauth2 spring spring

Two factor authentication with spring security oauth2


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@EnableAuthorizationServerpublic 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@EnableAuthorizationServerpublic 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;    }}