web-dev-qa-db-ja.com

Spring Security OAuth2による2要素認証

Spring Security OAuth2で2要素認証(2FA)を実装する方法のアイデアを探しています。要件は、ユーザーが機密情報を含む特定のアプリケーションに対してのみ2要素認証を必要とすることです。これらのウェブアプリには独自のクライアントIDがあります。

私の頭に浮かんだアイデアの1つは、スコープの承認ページを「誤用」して、ユーザーに2FAコード/ PIN(またはその他)を入力させることです。

サンプルフローは次のようになります。

2FAの有無にかかわらずアプリにアクセスする

  • ユーザーはログアウトしています
  • ユーザーが2FAを必要としないアプリAにアクセスする
  • OAuthアプリにリダイレクトします。ユーザーはユーザー名とパスワードでログインします
  • アプリAにリダイレクトされ、ユーザーはログインしています
  • ユーザーは、2FAも必要としないアプリBにアクセスします。
  • OAuthアプリにリダイレクトし、アプリBにリダイレクトして、ユーザーが直接ログインする
  • ユーザーがを実行するアプリSにアクセスする2FAが必要
  • OAuthアプリにリダイレクトします。ユーザーはさらに2FAトークンを提供する必要があります
  • アプリSにリダイレクトされ、ユーザーはログインしています

2FAでアプリに直接アクセス

  • ユーザーはログアウトしています
  • ユーザーがを実行するアプリSにアクセスする2FAが必要
  • OAuthアプリにリダイレクトします。ユーザーはユーザー名とパスワードでログインします。ユーザーは2FAトークンを追加で提供する必要があります
  • アプリSにリダイレクトされ、ユーザーはログインしています

これを割り当てる方法について他のアイデアはありますか?

21
James

これが、最終的に2要素認証が実装された方法です。

フィルターは、春のセキュリティフィルターの後に/ oauth/authorizeパスに登録されます。

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

このフィルターは、ユーザーが第2要素でまだ認証されていないかどうかを確認し(ROLE_TWO_FACTOR_AUTHENTICATED権限が利用できないかどうかを確認することによって)、OAuth AuthorizationRequestを作成しますこれにより、ユーザーは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;
    }
}

2FAコードの入力を処理するTwoFactorAuthenticationControllerは、コードが正しければ権限ROLE_TWO_FACTOR_AUTHENTICATEDを追加し、ユーザーを/ 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
    }
}

カスタムOAuth2RequestFactoryは、以前に保存されたAuthorizationRequestを利用可能な場合はセッションから取得し、それを返すか、セッションで見つからない場合は新しいものを作成します。

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

このカスタムOAuth2RequestFactoryは、次のように認証サーバーに設定されます。

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

Java configを使用する場合、TwoFactorAuthenticationInterceptorの代わりにTwoFactorAuthenticationFilterを作成し、AuthorizationServerConfigurerに登録して

@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には、TwoFactorAuthenticationFilterメソッドのpreHandleと同じロジックが含まれています。

23
James

受け入れられたソリューションを機能させることができませんでした。私はしばらくこれに取り組んでおり、最後に、ここで説明されているアイデアとこのスレッドで「 OAuth2 Multi-Factor Authenticationのnullクライアント 」を使用してソリューションを作成しました

ここに私のための作業ソリューションのGitHubの場所があります: https://github.com/turgos/oauth2-2FA

問題や改善策が見られた場合に備えて、フィードバックを共有していただければ幸いです。

以下に、このソリューションの主要な構成ファイルを示します。

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;
    }
}
1
turgos