使用 Spring Security oauth2 进行两因素身份验证

Posted

技术标签:

【中文标题】使用 Spring Security oauth2 进行两因素身份验证【英文标题】:Two factor authentication with spring security oauth2 【发布时间】:2015-07-30 22:18:24 【问题描述】:

我正在寻找如何使用 Spring Security OAuth2 实现两因素身份验证 (2FA) 的想法。要求是用户只需要对具有敏感信息的特定应用程序进行双重身份验证。这些 web 应用程序有自己的客户端 ID。

我想到的一个想法是“滥用”范围批准页面来强制用户输入 2FA 代码/PIN(或其他)。

示例流程如下所示:

在没有和使用 2FA 的情况下访问应用

用户已注销 用户访问不需要 2FA 的应用 A 重定向到 OAuth 应用,用户使用用户名和密码登录 重定向回应用 A 并且用户已登录 用户访问同样不需要 2FA 的应用 B 重定向到OAuth应用,重定向回应用B,用户直接登录 用户访问确实需要 2FA 的应用 S 重定向到 OAuth 应用,用户需要额外提供 2FA 令牌 重定向回应用 S 并且用户已登录

使用 2FA 直接访问应用

用户已注销 用户访问确实需要 2FA 的应用 S 重定向到 OAuth 应用,用户使用用户名和密码登录,用户需要额外提供 2FA 令牌 重定向回应用 S 并且用户已登录

你有其他想法如何解决这个问题吗?

【问题讨论】:

一个很棒的实现,IMO:Multi-factor Authentication with Spring Boot and OAuth2 @Cepr0 我已经尝试实现这一点,但第二步失败并在org.springframework.security.authentication.dao.DaoAuthenticationProvider 中抛出此错误:throw new BadCredentialsException(messages.getMessage( "AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials")); 任何线索? @AlexandreCassagne 也许我的例子会有所帮助:github.com/Cepr0/sb-oauth2-mfa-demo。但我需要警告您,密码授予已被弃用:github.com/spring-projects-experimental/… @Cepr0 谢谢分享!我使用这两个存储库学到了很多东西。但是我在查看您的代码时遇到了同样的问题,我不明白您如何将“N/A”或“”传递给 AuthenticationManager。就我而言,DaoAuthenticationProvider 不能接受空密码。 @AlexandreCassagne here 实现了 UserDetailsService(带有 lambda),它返回自定义 UserDetails(由 AuthenticationManager 使用)。而here 是使用“n/a”密码实现的自定义 UserDetails。 【参考方案1】:

这就是最终实现两因素身份验证的方式:

在spring安全过滤器之后为/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);
    

此过滤器检查用户是否尚未使用第二个因素进行身份验证(通过检查 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(如果可用)并返回该AuthorizationRequest,如果在会话中找不到则创建一个新的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 配置时,您可以创建 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 在其preHandle 方法中包含与TwoFactorAuthenticationFilter 相同的逻辑。

【讨论】:

我将 impl 更改为使用 java 8 sterams,因此不再需要 Query 和 Predicate 类。你可以找到AuthenticationUtil here。 我在 Spring Boot 中对类似算法的远下游实现投入了大量资金。似乎只在接近最后一步时失败。你愿意发表评论吗?这是链接:***.com/questions/37061697/… 很遗憾我没有示例应用,没有。【参考方案2】:

我无法使接受的解决方案发挥作用。我已经为此工作了一段时间,最后我使用此处解释的想法和此线程“null client in OAuth2 Multi-Factor Authentication”上的想法编写了我的解决方案

这里是我的工作解决方案的 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;
    

【讨论】:

虽然此链接可能会回答问题,但最好在此处包含答案的基本部分并提供链接以供参考。如果链接页面发生更改,仅链接答案可能会失效。 - From Review 感谢您的推荐。我添加了解释解决方案的关键配置文件。

以上是关于使用 Spring Security oauth2 进行两因素身份验证的主要内容,如果未能解决你的问题,请参考以下文章

使用 spring-session 和 spring-cloud-security 时,OAuth2ClientContext (spring-security-oauth2) 不会保留在 Redis

针对授权标头的Spring Security OAuth2 CORS问题

使用spring security jwt spring security oauth2权限控制遇到的坑记录

Spring Security 入门(1-3)Spring Security oauth2.0 指南

Spring Security OAuth2 - 如何使用 OAuth2Authentication 对象?

oauth2 spring-security 如果您在请求令牌或代码之前登录