Двухфакторная аутентификация с помощью spring безопасности oauth2

Я ищу идеи, как реализовать двухфакторную аутентификацию (2FA) с помощью spring безопасности OAuth2. Требование состоит в том, что пользователю требуется двухфакторная аутентификация только для конкретных приложений с конфиденциальной информацией. Эти веб-приложения имеют свои собственные идентификаторы клиентов.

Одна идея, которая возникла у меня в голове, заключалась бы в том, чтобы "неправильно использовать" страницу утверждения области, чтобы заставить пользователя вводить код 2FA/PIN (или что-то еще).

Пример потоков будет выглядеть так:

Доступ к приложениям без и с помощью 2FA

  • Пользователь вышел из системы
  • Пользователь обращается к приложению A, которое не требует 2FA
  • Перенаправление на приложение OAuth, вход в систему с именем пользователя и паролем
  • Перенаправлено обратно в приложение A и пользователь выполнил вход
  • Пользователь обращается к приложению B, которое также не требует 2FA
  • Перенаправление на приложение OAuth, перенаправление обратно в приложение B, а пользователь напрямую зарегистрирован
  • Пользователь получает доступ к приложению S, для которого требуется 2FA
  • Перенаправление на приложение OAuth, пользователю необходимо дополнительно предоставить токен 2FA
  • Перенаправлено обратно в приложение S и пользователь зарегистрирован

Прямой доступ к приложению с помощью 2FA

  • Пользователь вышел из системы
  • Пользователь получает доступ к приложению S, для которого требуется 2FA
  • Перенаправление на приложение OAuth, вход в систему с именем пользователя и паролем, пользователю необходимо дополнительно указать токен 2FA
  • Перенаправлено обратно в приложение S и пользователь зарегистрирован

Есть ли у вас другие идеи относительно этого?

Ответы

Ответ 1

Итак, наконец, реализована двухфакторная аутентификация:

Фильтр безопасности зарегистрирован для пути /oauth/authorize после фильтра безопасности spring:

@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 authorities (authorizationRequest.getAuthorities()) or the user 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;
    }
}

TwoFactorAuthenticationController, который обрабатывает ввод 2FA-кода, добавляет полномочия 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.