tutorial mvc example spring spring-security-oauth2 two-factor-authentication

mvc - spring security tutorial



Autenticación de dos factores con seguridad de primavera oauth2 (2)

Estoy buscando ideas sobre cómo implementar la autenticación de dos factores (2FA) con Spring security OAuth2. El requisito es que el usuario necesita autenticación de dos factores solo para aplicaciones específicas con información confidencial. Esas aplicaciones web tienen sus propios identificadores de cliente.

Una idea que surgió en mi mente sería "hacer un mal uso" de la página de aprobación del alcance para obligar al usuario a ingresar el código / PIN 2FA (o lo que sea).

Los flujos de muestra se verían así:

Acceso a aplicaciones sin y con 2FA

  • El usuario está desconectado
  • El usuario accede a la aplicación A que no requiere 2FA
  • Redirigir a la aplicación OAuth, el usuario inicia sesión con nombre de usuario y contraseña
  • Redirigido a la aplicación A y el usuario ha iniciado sesión
  • El usuario accede a la aplicación B que tampoco requiere 2FA
  • Redirigir a la aplicación OAuth, redirigir nuevamente a la aplicación B y el usuario inicia sesión directamente
  • Usuario accede a la aplicación S que requiere 2FA
  • Redirigir a la aplicación OAuth, el usuario debe proporcionar adicionalmente el token 2FA
  • Redirigido a la aplicación S y el usuario ha iniciado sesión

Acceso directo a la aplicación con 2FA

  • El usuario está desconectado
  • Usuario accede a la aplicación S que requiere 2FA
  • Redirigir a la aplicación OAuth, el usuario inicia sesión con nombre de usuario y contraseña, el usuario debe proporcionar adicionalmente el token 2FA
  • Redirigido a la aplicación S y el usuario ha iniciado sesión

¿Tienes otras ideas de cómo abordar esto?


Así es como se ha implementado finalmente la autenticación de dos factores:

Se ha registrado un filtro para la ruta / oauth / authorize después del filtro de seguridad de primavera:

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

Este filtro verifica si el usuario no se ha autenticado con un segundo factor (al verificar si la autorización ROLE_TWO_FACTOR_AUTHENTICATED no está disponible) y crea un OAuth AuthorizationRequest que se coloca en la sesión. El usuario es redirigido a la página donde debe ingresar el código 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; } }

El TwoFactorAuthenticationController que controla el ingreso del código 2FA agrega la autoridad ROLE_TWO_FACTOR_AUTHENTICATED si el código era correcto y redirige al usuario al punto final / 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 personalizado recupera el AuthorizationRequest guardado previamente de la sesión si está disponible y lo devuelve o crea uno nuevo si no se puede encontrar ninguno en la sesión.

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

Este OAuth2RequestFactory personalizado se establece en el servidor de autorización como:

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

Al usar la configuración de Java, puede crear un TwoFactorAuthenticationInterceptor lugar del TwoFactorAuthenticationFilter y registrarlo con un AuthorizationServerConfigurer con

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

El TwoFactorAuthenticationInterceptor contiene la misma lógica que TwoFactorAuthenticationFilter en su método preHandle .


No pude hacer funcionar la solución aceptada. He estado trabajando en esto por un tiempo, y finalmente escribí mi solución usando las ideas explicadas aquí y en este hilo " Cliente nulo en la autenticación multifactor OAuth2 "

Aquí está la ubicación de GitHub para la solución de trabajo para mí: https://github.com/turgos/oauth2-2FA

Aprecio si comparte sus comentarios en caso de que vea algún problema o un mejor enfoque.

A continuación puede encontrar los archivos de configuración clave para esta solución.

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