web-dev-qa-db-fra.com

Authentification à deux facteurs avec sécurité de printemps oauth2

Je cherche des idées sur la façon de mettre en œuvre l'authentification à deux facteurs (2FA) avec la sécurité de printemps OAuth2. L'obligation est que l'utilisateur ait besoin d'une authentification à deux facteurs uniquement pour des applications spécifiques avec des informations sensibles. Ces applications Web ont leur propre identifiant client.

Une idée qui m'est venue à l'esprit serait de "mal utiliser" la page d'approbation de la portée pour obliger l'utilisateur à entrer le code/code 2FA (ou autre).

Les flux d'échantillon ressemblent à ceci:

Accéder aux applications sans et avec 2FA

  • L'utilisateur est déconnecté
  • L'utilisateur accède à l'application A qui ne nécessite pas 2FA
  • Rediriger vers l'application OAuth, l'utilisateur se connecte avec un nom d'utilisateur et un mot de passe
  • Redirigé vers l'application A et l'utilisateur est connecté
  • L'utilisateur accède à l'application B qui n'exige pas également 2FA
  • Rediriger vers l'application OAuth, rediriger vers l'application B et l'utilisateur est directement connecté
  • L'utilisateur accède à l'application S qui ne requiert 2FA
  • Rediriger vers l'application OAuth, l'utilisateur doit également fournir le jeton 2FA
  • Redirigé vers l'application S et l'utilisateur est connecté

Accéder directement à l'application avec 2FA

  • L'utilisateur est déconnecté
  • L'utilisateur accède à l'application S qui ne requiert 2FA
  • Redirection vers l'application OAuth, l'utilisateur se connecte avec son nom d'utilisateur et son mot de passe. L'utilisateur doit également fournir le jeton 2FA.
  • Redirigé vers l'application S et l'utilisateur est connecté

Avez-vous d'autres idées sur la façon de résoudre ce problème?

18
James

Voici donc comment l’authentification à deux facteurs a finalement été mise en place:

Un filtre est enregistré pour le chemin/oauth/authorize après le filtre de sécurité du ressort:

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

Ce filtre vérifie si l'utilisateur ne s'est pas déjà authentifié avec un 2e facteur (en vérifiant si l'autorité ROLE_TWO_FACTOR_AUTHENTICATED n'est pas disponible) et crée une variable OAuth AuthorizationRequest qui est placée dans la session. L'utilisateur est ensuite redirigé vers la page où il doit entrer le code 2FA:

/**
 * 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;
    }
}

La TwoFactorAuthenticationController qui gère la saisie du code 2FA ajoute l'autorité ROLE_TWO_FACTOR_AUTHENTICATED si le code était correct et redirige l'utilisateur vers le point de terminaison/oauth/authorize.

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

Un OAuth2RequestFactory personnalisé récupère la AuthorizationRequest précédemment enregistrée de la session, s'il est disponible, et le renvoie ou en crée un nouveau s'il n'en existe aucun dans la 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);
    }
}

Cette OAuth2RequestFactory personnalisée est définie sur le serveur d'autorisations comme suit:

<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>

Lors de l’utilisation de Java config, vous pouvez créer une TwoFactorAuthenticationInterceptor au lieu de TwoFactorAuthenticationFilter et l’enregistrer avec une AuthorizationServerConfigurer avec

@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();
    }
}

TwoFactorAuthenticationInterceptor contient la même logique que TwoFactorAuthenticationFilter dans sa méthode preHandle.

23
James

Je ne pouvais pas faire fonctionner la solution acceptée. J'y travaille depuis un certain temps et j'ai finalement écrit ma solution en utilisant les idées expliquées ici et sur ce fil " client nul dans l'authentification à plusieurs facteurs OAuth2 "

Voici l'emplacement de GitHub pour la solution de travail pour moi: https://github.com/turgos/oauth2-2FA

J'apprécie que vous partagiez vos commentaires au cas où vous verriez des problèmes ou une meilleure approche.

Vous trouverez ci-dessous les principaux fichiers de configuration pour cette 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;
    }
}
0
turgos