DEV Community

Federico Herrera
Federico Herrera

Posted on

Llegó MFA con Spring Security 7

Desde su salida, Spring Security no ofreció soporte nativo para Multi-Factor Authentication (MFA). Implementar un segundo factor como un código temporal, de esos que te mandan un codigo por SMS o Whatsapp, una app autenticadora o un token, implicaba crear filtros personalizados, AuthenticationProviders propios y manejar estados intermedios de autenticación a mano. Cada implementación terminaba siendo distinta.

Finalmente, Spring Security 7 lo hizo. El framework introduce soporte oficial y declarativo para MFA, integrándolo directamente en su modelo de autenticación y autorización. Ahora, cada factor autenticado se representa como una autoridad, permitiendo definir reglas de acceso que exigen múltiples factores sin necesidad de lógica personalizada ni flujos improvisados.

Ahora vamos a ver qué resuelve este nuevo soporte y cómo implementar MFA de manera simple y consistente.

Naturalmente, la fuente final de la verdad es la documentación oficial, que se mantiene actualizada por los desarrolladores y la comunidad.

¿Qué es MFA?

Hay un montón de recursos online para responder esta pregunta así que a efectos de este post lo vamos a simplificar, si quieres profundizar, consúltale a Google o a tu IA de confianza.

Básicamente, MFA (Multi-Factor Authentication) es un mecanismo de autenticación que requiere de 2 o más formas de probar que eres quien dices ser para acceder a un recurso o sección de una aplicación. La forma más común, y que posiblemente ya conozcas, de esta técnica, es cuando al loguearte a una aplicación usando tu usuario y contraseña o tu cuenta de mail, necesites, además, validar un código que el sistema te envía a tu celular mediante SMS o Whatsapp o que hagas click en un enlace que te envien por cualquier medio. Otras formas involucran biometría, como tu huella dactilar o escáner facial, en la mayoria de dispositivos moviles, o una llave física, por lo general la empresa para la que trabajes puede llegarte a brindar uno de estos dispositivos.

¿Cómo lo implemento en mi proyecto?

Sin mas preámbulo, vamos a ver cómo implementar estos mecanismos en tu aplicación BE Spring.

Para no ser una duplicado de la guía de la documentación voy a hacer leverage de uno de los estándares más usados para autenticación, JWT.
Y es que por diseño Spring Security hace uso de sesiones, véase Session Management, pero la mayoría de las veces mantenemos RESTful APIs y necesitamos mantenerlas stateless para asegurar consistencia.

Un estándar muy utilizado es hacer uso de tokens, donde especificamos las capacidades del usuario una vez se autentica y este lo utiliza como tarjeta de entrada para acceder a los recursos del sistema.

Planteamos los siguientes requerimientos:
Diagrama de los requerimientos a implementar

POST /login

En caso de que sean las credenciales válidas, construimos el token, caso contrario retornamos.

GET /user/settings

  • Solo usuarios autenticados pueden acceder al recurso.
  • Autencicación por token soportada.
  • En caso de que el cliente provea un token y sea válido, puede acceder al recurso. Caso contrario retornamos.

Muy bien, para esto podemos hacer algo bastante simple en Spring, primeramente necesitamos algo que nos permita construir el token y decodificarlo/validarlo.

Para esta demo hago uso de las implementaciones estándar de Spring con certificados estáticos, algo que no es la mejor de las prácticas ni mucho menos recomendable para producción, pero didácticamente nos va a ayudar a reducir el , no voy a entrar en detalle de las implementaciones, pero puedes revisar el código del proyecto si te da curiosidad.

Entonces configuramos 2 beanes para llevar esto acabo, un NimbusJwtEncoder y un NimbusJwtDecoder, implementaciones estándar de Spring.

Además, necesitamos un usuario para pruebas, por lo que vamos a crear un service que tenga un usuario de pruebas en memoria.

  @Bean
  UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
    List<UserDetails> users =
        List.of(
            new User(
                "user",
                passwordEncoder.encode("password"),
                List.of(new SimpleGrantedAuthority("ROLE_USER"))));
    return new InMemoryUserDetailsManager(users);
  }
Enter fullscreen mode Exit fullscreen mode

Debemos decirle a Spring que un usuario va a poder autenticarse usando información que tenemos en el sistema, en producción seria una BBDD, para este caso in-memory. Esto lo conseguimos simplemente definiendo un AuthenticationProvider que haga uso de algo así: Spring nos entrega DaoAuthenticationProvider para esto.

  @Bean
  AuthenticationManager authenticationManager(
      UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) {
    DaoAuthenticationProvider authenticationProvider =
        new DaoAuthenticationProvider(userDetailsService);
    authenticationProvider.setPasswordEncoder(passwordEncoder);

    return new ProviderManager(authenticationProvider);
  }
Enter fullscreen mode Exit fullscreen mode

Vemos que éste depende de un PasswordEncoder, además del UserDetailsService que definimos antes, de nuevo, todo está escrito.

  @Bean
  PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
  }
Enter fullscreen mode Exit fullscreen mode

Este es todo nuestro setup, ahora solo debemos habilitar a cliente a hacer uso del servicio, un controller como este es suficiente:

@Slf4j
@RestController
@RequiredArgsConstructor
public class AuthController {
  private final AuthenticationManager authenticationManager;
  private final JwtEncoder jwtEncoder;

  @PostMapping(value = "/login", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
  @ResponseStatus(HttpStatus.NO_CONTENT)
  ResponseEntity<Void> login(@RequestParam String username, @RequestParam String password) {
    var authRequest = new UsernamePasswordAuthenticationToken(username, password);
    log.info("Login: {}", authRequest);
    var auth = authenticationManager.authenticate(authRequest);
    String token =
        jwtEncoder
            .encode(
                JwtEncoderParameters.from(
                    JwsHeader.with(SignatureAlgorithm.RS256).build(),
                    JwtClaimsSet.builder()
                        .subject(auth.getName())
                        .claim(
                            SCOPE,
                            auth.getAuthorities().stream()
                                .map(GrantedAuthority::getAuthority)
                                .toList())
                        .expiresAt(Instant.now().plusSeconds(1000))
                        .build()))
            .getTokenValue();
    return ResponseEntity.noContent().header(AUTHORIZATION, "Bearer %s".formatted(token)).build();
  }
}
Enter fullscreen mode Exit fullscreen mode

Una capa de servicio podría facilitar la segmentación pero a efectos de esta demo podemos tenerlo todo en el controlador.

Vamos por partes aquí, primero nos traemos el AuthenticationManager, como tenemos el método de autenticación por usuario y contraseña, creamos una instancia de UsernamePasswordAuthenticationToken que luego podemos pasar al auth provider, éste se va a encargar de decirnos si es válido.
Si pasa es porque las credenciales son correctas, por lo que procedemos a construir el token especificando el algoritmo de la firma, el subject que es el mismo username del usuario y agregamos las claims authorities del usuario, que nos permite despues saber cosas como el rol y cómo se autenticó. Ésta es la forma en la que Spring Security sabe a qué cosas puede acceder el usuario. Docs Authorities en Spring

Con ésto en su lugar, solo nos queda decirle a Spring como descomponer un token JWT y obtener sus authorities a partir de el, otro escenario tan común que ya lo tenemos con mínima configuración.

@Configuration
public class JwtAuthConfig {
  @Bean
  JwtAuthenticationConverter jwtAuthenticationConverter(
      JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter) {
    val jwtAuthenticationConverter = new JwtAuthenticationConverter();
    jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);
    return jwtAuthenticationConverter;
  }

  @Bean
  JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter() {
    val jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
    jwtGrantedAuthoritiesConverter.setAuthorityPrefix("");
    return jwtGrantedAuthoritiesConverter;
  }
}
Enter fullscreen mode Exit fullscreen mode

Bastante autodeclarativo, pero solo le digo que necesito un conversor de JWT a Authentication y que no le ponga ningún prefijo a las Authorities que cree a partir del token.

Con esto tenemos casi todo lo que necesitamos, solo nos queda decirle a Spring que éste endpoint es público.

  @Bean
  SecurityFilterChain securityFilterChain(
      HttpSecurity http, JwtAuthenticationConverter jwtAuthenticationConverter) {
    http.csrf(AbstractHttpConfigurer::disable)
        .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(
            authorize ->
                authorize
                    .requestMatchers("/login")
                    .permitAll()
                    .anyRequest()
                    .authenticated())
        .oauth2ResourceServer(
            oauth -> oauth.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter)));
    return http.build();
  }
Enter fullscreen mode Exit fullscreen mode

Definimos que la SecurityFilterChain desabilite el CSRF y que usaremos sesiones sin estado. El /login estará abierto y cualquier otro request necesita estar autenticado. Al final, le indicamos que el server Oauth2 use el JwtAuthenticationConverter que definimos anteriormente.

Por supuesto necesitamos testearlo:

  @Test
  void mfaTest() {
    MockHttpServletResponse response =
        mockMvcTester
            .post()
            .contentType(MediaType.APPLICATION_FORM_URLENCODED)
            .uri("/login")
            .param("username", "user")
            .param("password", "password")
            .exchange()
            .assertThat()
            .containsHeader(AUTHORIZATION)
            .actual()
            .getResponse();

    var token = requireNonNull(response.getHeader(AUTHORIZATION));
    var decoded = jwtDecoder.decode(token.replace("Bearer ", ""));
    assertThat(decoded.getSubject()).isEqualTo("user");
    assertThat(decoded.getClaimAsStringList(SCOPE)).contains("ROLE_USER");
  }
Enter fullscreen mode Exit fullscreen mode

Aquí me traigo el NimbusJwtDecoder que utiliza la aplicación para decodificar y valido que el token que me genera el endpoint es válido. Las credenciales son las que definí en mi UserDetailsService en memoria.

Con esto en su lugar, puedo habilitar el servicio de /user/settings:

@RestController
@RequestMapping("/user")
public class UserController {

  @GetMapping("/settings")
  ResponseEntity<Map<String, String>> getSettings() {
    return ResponseEntity.ok(Map.of("found", "config"));
  }
}
Enter fullscreen mode Exit fullscreen mode

Hermosa configuración.

Y securizarlo:

  @Bean
  SecurityFilterChain securityFilterChain(
      HttpSecurity http, JwtAuthenticationConverter jwtAuthenticationConverter) {
    http
    // etc etc
        .authorizeHttpRequests(
            authorize ->
                authorize
                    .requestMatchers("/login")
                    .permitAll()
                    .requestMatchers("/user/settings")
                    .hasRole("USER")
                    .anyRequest()
                    .authenticated())
    // etc etc
    return http.build();
  }
Enter fullscreen mode Exit fullscreen mode

El JwtAuthenticationConverter que definí antes va a leer el token y asígnar el Authority con el rol correspondiente.

Agregando MFA

Resulta que ahora queremos que, además de que el usuario esté autenticado, valide un código que recibirá por mail o SMS cuando quiera acceder a las settings.
Ahora el requerimiento de seguridad para acceder a las settings es así:
Diagrama con 2 métodos de autenticación
En caso de que alguna condición falle, siempre REJECT.

Para esto vamos a crear un endpoint donde el cliente puede solicitar un OTT, podríamos dispararlo directamente del endpoint cuando falle, pero sería un acople innecesario.

Veamos entonces la implementación:

@RestController
@RequestMapping("/ott")
@RequiredArgsConstructor
public class OttController {
  private final JwtEncoder jwtEncoder;
  private final Map<String, OneTimeToken> tokens = new HashMap<>();

  @PostMapping
  @ResponseStatus(HttpStatus.NO_CONTENT)
  void getOttCode() {
    var auth = requireNonNull(SecurityContextHolder.getContext().getAuthentication());
    var ott =
        new DefaultOneTimeToken(
            UUID.randomUUID().toString(), auth.getName(), Instant.now().plusSeconds(1000));

    tokens.put(auth.getName(), ott);
  }
}
Enter fullscreen mode Exit fullscreen mode

A efectos de una demo, vamos a guardar los tokens en un mapa, en producción, este storage debería ser un cache distribuido o datastore con TTL.

Creamos un método publico para validarlo en el test, que lo extendemos así:

    mockMvcTester
        .post()
        .contentType(MediaType.APPLICATION_JSON)
        .uri("/ott")
        .header(AUTHORIZATION, token)
        .exchange()
        .assertThat()
        .hasStatus(HttpStatus.NO_CONTENT);

    assertThat(ottController.getToken("user")).isNotNull();
Enter fullscreen mode Exit fullscreen mode

Muy sencillito.

El soporte de MFA existe, pero no se activa automáticamente, así que lo habilitamos agregando la anotación @EnableMultiFactorAuthentication(authorities = {}) a nuestra clase de configuracion de Spring Security, authorities vacías ya que especificar aquí los métodos de autenticación los habilitaria globalmente.

Para obtener el token OTT dijimos que el usuario debería estar autenticado de antemano, como ahora tenemos multiples factores, tenemos que decirle a Spring Security cómo debería estar autenticado para obtenerlo, así que nuestro filter chain quedaría así:

  @Bean
  SecurityFilterChain securityFilterChain(
      HttpSecurity http, JwtAuthenticationConverter jwtAuthenticationConverter) {
    val bearerFactor =
        AuthorizationManagerFactories.multiFactor()
            .requireFactors(FactorGrantedAuthority.BEARER_AUTHORITY)
            .build();
    http
    // etc etc
        .authorizeHttpRequests(
            authorize ->
                authorize
                    .requestMatchers("/ott")
                    .access(bearerFactor.hasRole("USER"))
                    .anyRequest()
                    .authenticated());
    // etc etc
    return http.build();
  }
Enter fullscreen mode Exit fullscreen mode

¿Qué está pasando aquí?
Ahora necesitamos un manager de autorización que valide que el usuario se autenticó con Bearer Token, JWT.
Y le decimos que para invocar /ott necesita éste método de autenticación además de tener el rol USER.
Con el setup que tenemos, esto es todo lo que necesitamos, al utilizar un JwtAuthenticationConverter éste agrega por default FactorGrantedAuthority#BEARER_AUTHORITY a la autenticación del contexto.

Entonces ahora solo necesitamos validar el token OTT, en producción éste lo mandaríamos por mail o SMS, aquí, vive en memoría y lo obtengo con un método público en el controlador.

  @PostMapping("/login")
  @ResponseStatus(HttpStatus.NO_CONTENT)
  ResponseEntity<Void> validateOtt(@RequestParam String ott) {
    var auth = requireNonNull(SecurityContextHolder.getContext().getAuthentication());
    var ottToken = tokens.get(auth.getName());
    if (isNull(ottToken) || !ottToken.getTokenValue().equals(ott)) {
      throw new IllegalArgumentException();
    }
    List<String> authorities =
        Stream.of(
                List.of(FactorGrantedAuthority.fromAuthority(FactorGrantedAuthority.OTT_AUTHORITY)),
                auth.getAuthorities())
            .flatMap(Collection::stream)
            .map(GrantedAuthority::getAuthority)
            .toList();
    String token =
        jwtEncoder
            .encode(
                JwtEncoderParameters.from(
                    JwsHeader.with(SignatureAlgorithm.RS256).build(),
                    JwtClaimsSet.builder()
                        .subject(auth.getName())
                        .claim(SCOPE, authorities)
                        .expiresAt(Instant.now().plusSeconds(1000))
                        .build()))
            .getTokenValue();
    return ResponseEntity.noContent().header(AUTHORIZATION, "Bearer %s".formatted(token)).build();
  }

  public OneTimeToken getToken(String username) {
    return tokens.get(username);
  }
Enter fullscreen mode Exit fullscreen mode

El usuario debe pegarle al endpoint con el código que "le llegó por mail" y, si existe, le agregamos a los authorities el FactorGrantedAuthority#OTT_AUTHORITY indicándo que se autenticó correctamente con éste metodo y creamos un nuevo tóken con toda la información que ya tenía, que luego puede usar para buscar sus settings.

Lo último sería completar nuestro test:

    response =
        mockMvcTester
            .post()
            .uri("/ott/login")
            .queryParam("ott", ott)
            .header(AUTHORIZATION, token)
            .exchange()
            .assertThat()
            .debug()
            .hasStatus(HttpStatus.NO_CONTENT)
            .containsHeader(AUTHORIZATION)
            .extracting(MvcTestResult::getResponse)
            .actual();

    var auth = response.getHeader(AUTHORIZATION);
    assertThat(auth).isNotNull();
    var updatedToken = auth.replace("Bearer ", "");
    assertThatNoException().isThrownBy(() -> jwtDecoder.decode(updatedToken));
    decoded = jwtDecoder.decode(updatedToken);
    assertThat(decoded.getSubject()).isEqualTo("user");
    assertThat(decoded.getClaimAsStringList(SCOPE)).contains("FACTOR_OTT", "FACTOR_BEARER");
Enter fullscreen mode Exit fullscreen mode

Donde testeamos que, con el token que teniamos de la autenticación con contraseña podemos acceder a /ott/login y obtener el nuevo token con ambos factores de autenticación.

El soporte nativo de MFA en Spring Security 7 no introduce nuevos mecanismos de verificación por sí mismo, sino algo mucho más importante: un modelo consistente para representarlos y exigirlos.

En lugar de flujos especiales, estados intermedios o lógica dispersa en filtros y controladores, cada factor de autenticación pasa a formar parte explícita del Authentication del usuario, expresado como autoridades verificables. Esto permite que la autorización se base en hechos comprobables, qué factores fueron validados, y no en suposiciones implícitas o banderas temporales.

Este enfoque encaja naturalmente con arquitecturas stateless, JWT y APIs REST, y se alinea con cómo los sistemas de identidad modernos modelan la autenticación progresiva o step-up authentication. El resultado es un sistema más simple de razonar, más fácil de mantener y mucho menos propenso a errores sutiles de seguridad.

Spring Security 7 no hace que MFA sea “mágico”, pero finalmente lo hace predecible, declarativo y bien integrado en su ecosistema. Y eso, para quienes ya tuvieron que implementarlo a mano más de una vez, es un cambio enorme.

Como siempre, todo el código está disponible para que lo revises en el proyecto de github

Top comments (0)