Implementar un segundo factor de autenticación en una aplicación web Java con Spring

Escrito por el .
java planeta-codigo programacion seguridad spring
Comentarios

El segundo factor de autenticación es una medida adicional en la autenticación que proporciona una notable mayor seguridad que utilizar solo un usuario y contraseña. Utilizando Spring y la aplicación para smatphone Google Authenticator se puede implementar en una aplicación Java el segundo factor de autenticación o 2FA con códigos temporales o TOTP.

Java
Spring

Comúnmente para realizar el proceso de autenticar a un usuario se ha realizado simplemente con el método de usuario y contraseña. Sin embargo, verificar la identidad mediante usuario y contraseña para algunos usuarios no es suficientemente seguro dado que los usuarios pueden elegir contraseñas débiles con pocos caracteres o sin usar una combinación que incluya letras, números y símbolos, pueden elegir contraseñas comunes muy utilizadas fáciles de adivinar con un ataque de diccionario, pueden usar la misma contraseña para varios servicios de modo que si las contraseñas de un servicio son descubiertas cualquier otro servicio que las utilice potencialmente corre un riesgo de seguridad. Usar solo usuario y contraseña no proporciona la suficiente seguridad para ciertos servicios que permiten realizar transacciones que involucra dinero, tratan datos sensibles o son servicios atractivos para ser atacados.

Para que las contraseñas sean seguras las aplicaciones en sus bases de datos guardar las contraseñas usando Salted Password Hashing, los usuarios por su parte deben utilizar un generador de contraseñas, utilizar una contraseña distinta para cada servicio y guardalas en una base de datos cifrada como KeePassXC para recordar cada una de ellas. Las contraseñas son algo que se conoce, cualquier persona que conozca la contraseña puede autenticarse, más recientemente una capa adicional de seguridad es requerir algo que se tiene, el segundo factor de autenticación o 2FA.

La aplicación Google Authenticator para dispositivos móviles Android permite utilizarse como segundo factor de autenticación, esta aplicación genera códigos con un tiempo corto de duración que son requeridos en un segundo paso de la autenticación después de introducir el usuario y contraseña. Con un segundo factor de autenticación se requiere algo que se sabe, el usuario y contraseña, y algo que se tiene, el dispositivo móvil que genera códigos con lo que aunque la contraseña quede comprometida no se podría realizar la autenticación sin poseer el segundo factor de autenticación.

Dado que los códigos de verificación tienen un tiempo de vida corto, habitualmente de 30 segundos, y acceder al generador del segundo factor de autenticación requiere acceso físico al dispositivo móvil la combinación de que las credenciales queden comprometidas es significativamente más difícil y por tanto la seguridad aumenta al mismo tiempo. Los principales servicios de internet como Google, Amazon, Twitter y otros servicios utilizados por millones de usuarios permiten ya utilizar 2FA, un fallo en su seguridad por la cantidad de usuarios e importante información que registran les supodría una muy mala imagen, pérdida de ingresos, costes, reputación, usuarios o dependiendo de la gravedad del fallo y los datos comprometidos multas millonarias.

A través de Spring Security y la librería aerogear-otp-java una aplicación Java puede implementar el segundo factor de autenticación, incluso posibilitar de que el requerimiento de solicitar segundo factor de autenticación sea opcional según la preferencia de un usuario o como forma de que los usuarios progresivamente habiliten el 2FA. El primer paso es proporcionar al usuario una clave secreta a través de un código QR que codifica una clave secreta que se utiliza para generar los códigos de verificación, el usuario debe escanearlo con la aplicación Google Authenticator con la cámara para que genere código de 6 dígitos con una validez de 30 segundos en el momento de autenticarse, este paso se realiza en el momento de registrarse o de activar el 2FA si es opcional. Con Google Authenticator el código en vez con la cámara también se puede introducir mediante el teclado si la aplicación se lo proporciona en forma de texto en vez de como imagen QR. La ventaja del código QR es que es más rápido y cómodo.

El primer paso de la autenticación utilizando 2FA es introducir el usuario y contraseña. El segundo paso es introducir el código del segundo factor de autenticación. Introducidos ambos el usuario es redirigido a la página de inicio.

Autenticación con segundo factor de autenticación
Aplicación Google Authenticator con varios generadores de códigos temporales

Validado el código del 2FA al usuario se le asignan los permisos que le corresponden en el sistema y que le otorgan permisos para realizar acciones, en este caso entrar a la página de inicio.

La implementación en código contiene las clases que representan una cuenta en el sistema, en InMemoryAccountRepository se crean dos usuarios admin y user con sus contraseñas en el ejemplo en texto plano y los roles que tiene asignados que les otorgarán permisos para realizar acciones en la aplicación.

1
2
3
4
5
6
package io.github.picodotdev.blogbitix.spring2fa.account;

public interface AccountRepository {

    Account find(String username);
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package io.github.picodotdev.blogbitix.spring2fa.account;

...

@Repository
public class InMemoryAccountRepository implements AccountRepository {

    private static String ADMIN_SECRET = "6YFX5TVT76OHHNMS";

    private List<Account> accounts;

    public InMemoryAccountRepository() {
        accounts = new ArrayList<Account>();
        init();
    }

    private void init() {
        Account admin = new Account();
        admin.setUsername("admin");
        admin.setPassword("{noop}password");
        admin.setAuth2fa(true);
        admin.setSecret(ADMIN_SECRET);
        admin.setRoles(Arrays.asList("ROLE_USER"));

        Account user = new Account();
        user.setUsername("user");
        user.setPassword("{noop}password");
        user.setAuth2fa(false);
        user.setRoles(Arrays.asList("ROLE_USER"));

        accounts.add(admin);
        accounts.add(user);
    }

    @Override
    public Account find(String username) {
        return accounts.stream().filter(account -> account.getUsername().equals(username)).findFirst().orElse(null);
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
package io.github.picodotdev.blogbitix.spring2fa.account;

...

public class Account {

    private String username;
    private String password;

    private String secret;
    private Boolean auth2fa;

    private List<String> roles;

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public String getSecret() {
        return secret;
    }

    public void setSecret(String secret) {
        this.secret = secret;
    }

    public Boolean isAuth2fa() {
        return auth2fa;
    }

    public void setAuth2fa(Boolean auth2fa) {
        this.auth2fa = auth2fa;
    }

    public List<String> getRoles() {
        return roles;
    }

    public void setRoles(List<String> roles) {
        this.roles = roles;
    }
}

La configuración de seguridad en Spring Security indica para cada URL que permisos se requieren. Para acceder a la página de contenido /home de la aplicación se requiere el rol USER, a la página de inicio de sesión /login se permite acceder a los usuario no autenticados donde introducen sus credenciales de usuario y contraseña, una vez validado el usuario y contraseña el usuario autenticado tiene el rol PRE_AUTH_USER, dependiendo de si el usuario en su prefrencia usa 2FA o no en el manejador de autenticación exitosa SecondFactorAuthenticationSuccessHandler redirige al usuario a la página /home o la página /code para intorducir el código de verificación del segundo factor autenticación. Al usuario autenticado exitosamente de forma completa se le sustituye el permiso PRE_AUTH_USER por los que tenga asignado, en el ejemplo el rol USER.

La verificación del código del segundo paso de autenticación se realiza en la clase CodeController con la clase Totp a partir del código enviado y el código secreto con el cual se generó la imagen de código QR.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package io.github.picodotdev.blogbitix.spring2fa.spring;

...

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private AuthenticationSuccessHandler authenticationSuccessHandler;

    @Bean
    public PasswordEncoder encoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    @Bean
    public AuthenticationSuccessHandler authenticationSuccessHandler() {
        return new SecondFactorAuthenticationSuccessHandler();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/static/**").permitAll()
                .antMatchers("/code").hasRole("PRE_AUTH_USER")
                .antMatchers("/home").hasRole("USER")
                .anyRequest().authenticated();

        http.formLogin()
                .loginPage("/login")
                .permitAll()
                .successHandler(authenticationSuccessHandler);

        http.logout()
                .permitAll();
    }

    @Autowired
    public void registerAuthentication(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService);
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package io.github.picodotdev.blogbitix.spring2fa.spring;

...

@EnableWebMvc
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Override
    public void addViewControllers(ViewControllerRegistry registry) {
        registry.addViewController("/login").setViewName("login");
        registry.addViewController("/code").setViewName("code");
        registry.addViewController("/home").setViewName("home");
    }

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/static/**").addResourceLocations("classpath:/static/");
    }

    @Bean
    public ClassLoaderTemplateResolver templateResolver() {
        ClassLoaderTemplateResolver result = new ClassLoaderTemplateResolver();
        result.setPrefix("templates/");
        result.setSuffix(".html");
        result.setTemplateMode("HTML");
        return result;
    }

    @Bean
    public SpringTemplateEngine templateEngine(ClassLoaderTemplateResolver templateResolver) {
        SpringTemplateEngine templateEngine = new SpringTemplateEngine();
        templateEngine.addDialect(new LayoutDialect());
        templateEngine.setTemplateResolver(templateResolver);
        return templateEngine;
    }

    @Bean
    public ThymeleafViewResolver viewResolver(SpringTemplateEngine engine) {
        ThymeleafViewResolver viewResolver = new ThymeleafViewResolver();
        viewResolver.setTemplateEngine(engine);
        viewResolver.setCache(false);
        return viewResolver;
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package io.github.picodotdev.blogbitix.spring2fa.spring;

...

public class SecondFactorAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
  
    private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
 
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
        handle(request, response, authentication);
        clearAuthenticationAttributes(request);
    }
 
    protected void handle(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
          String targetUrl = getTargetUrl(authentication);
 
        if (response.isCommitted()) {
            return;
        }
 
        redirectStrategy.sendRedirect(request, response, targetUrl);
    }
 
    protected String getTargetUrl(Authentication authentication) {
        UserDetailsAdapter userDetailsAdapter = (UserDetailsAdapter) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        if (userDetailsAdapter.getAccount().isAuth2fa()) {
            return "/code";
        } else {
            Utils.setAuthentication();
            return "/home";
        }
    }
 
    protected void clearAuthenticationAttributes(HttpServletRequest request) {
        HttpSession session = request.getSession(false);
        if (session == null) {
            return;
        }
        session.removeAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);
    }
 
    public void setRedirectStrategy(RedirectStrategy redirectStrategy) {
        this.redirectStrategy = redirectStrategy;
    }
    protected RedirectStrategy getRedirectStrategy() {
        return redirectStrategy;
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package io.github.picodotdev.blogbitix.spring2fa.spring;

...

public class Utils {

    public static void setAuthentication() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        UserDetailsAdapter userDetailsAdapter = (UserDetailsAdapter) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        List<GrantedAuthority> authorities = AuthorityUtils.createAuthorityList(userDetailsAdapter.getAccount().getRoles().toArray(new String[0]));

        Authentication newAuth = new UsernamePasswordAuthenticationToken(authentication.getPrincipal(), authentication.getCredentials(), authorities);
        SecurityContextHolder.getContext().setAuthentication(newAuth);
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package io.github.picodotdev.blogbitix.spring2fa.spring;

...

public class UserDetailsAdapter implements UserDetails {

    private Account account;

    public UserDetailsAdapter(Account account) {
        this.account = account;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return AuthorityUtils.createAuthorityList("ROLE_PRE_AUTH_USER");
    }

    @Override
    public String getUsername() {
        return account.getUsername();
    }

    @Override
    public String getPassword() {
        return account.getPassword();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }

    public Account getAccount() {
        return account;
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package io.github.picodotdev.blogbitix.spring2fa.spring;

...

@Component
@Primary
public class UserDetailsServiceAdapter implements UserDetailsService {

    @Autowired
    private AccountRepository accountRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Account account = accountRepository.find(username);

        if (account == null) {
            throw new UsernameNotFoundException(username);
        }

        return new UserDetailsAdapter(account);
    }
}

El código QR es una imagen generada a partir del código secreto y una información adicional que al usurio le permite identificar la cuenta, hay webs que permiten decodificar una imagen QR para analizar que información incorpora, en esta la información de la cuenta Spring2FA (admin) y el secreto 6YFX5TVT76OHHNMS utilizado para generar los códigos temporales. En el HTML devuelto se incluye una imagen con la información embebida en el enlace de la imagen, la imagen se genera por un servicio de Google.

Decodificador de imágenes código QR
1
2
3
4
5
6
7
8
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/web/thymeleaf/layout" layout:decorate="~{layout}">
<body>
    ...
    <p><img src="https://chart.googleapis.com/chart?chs=200x200&chld=M%7C0&cht=qr&chl=otpauth%3A%2F%2Ftotp%2FSpring2FA%20(admin)%3Fsecret%3D6YFX5TVT76OHHNMS" /></p>
    ...
</body>
</html>

Las dependencias de librerías son las siguientes.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
...

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.thymeleaf:thymeleaf-spring5:3.0.11.RELEASE'
    implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:2.4.1'
    implementation 'org.jboss.aerogear:aerogear-otp-java:1.0.0'
}

Este ejemplo está hecho con la infraestructura que proporciona Spring pero el proceso de autenticación es igualmente implementable con cualquier otro framework o librería.

Muchos de los servicios populares en internet implementan 2FA como medida de proteger las cuentas de los usuarios y la información en esos servicios. Hay bancos que como contraseña de acceso solo tienen un número de seis dígitos con el riesgo que representa sus usuarios por la importancia que tiene la banca en línea de los datos que se trata.

El código fuente completo del ejemplo puedes descargarlo del repositorio de ejemplos de Blog Bitix alojado en GitHub y probarlo en tu equipo ejecutando el comando ./gradlew run.