https://d226lax1qjow5r.cloudfront.net/blog/blogposts/otp-based-jwt-authentication-in-spring-boot-with-vonage-verify-api/jwt-authentication_spring-boot_verify-api.png

Autenticación basada en OTP (JWT) en Spring Boot con Vonage Verify API

Publicado el November 17, 2022

Tiempo de lectura: 7 minutos

Este artículo se actualizó en marzo de 2025

Visión general

Una experiencia de usuario (UX) fluida es un factor clave para el crecimiento de los productos. La autenticación es una parte integral de una buena UX, especialmente en aplicaciones bancarias o FinTech.

Imagine tener que crear una página de inicio de sesión para un banco en la que el cliente utilice un número de teléfono y una contraseña de un solo uso (OTP) para autenticarse. Eliminar la necesidad de recordar una contraseña al tiempo que se ofrece seguridad mejoraría la experiencia del usuario.

Veamos cómo podemos crear una autenticación JWT (JSON Web Token) basada en OTP en Java. Utilizaremos el framework Spring Boot y la Verify API de Vonage.

Más información sobre JWT aquí.

Cuenta API de Vonage

Antes de empezar a utilizar la API, necesitaremos una cuenta de API de Vonage.

Vonage API dashboard

Una vez que tengamos nuestra clave API y secreto APIlos utilizaremos para la función Verify API.

Configurar

Crearemos una nueva aplicación Spring e importaremos el SDK de servidor Java de Vonage para poder utilizar las API de Vonage en nuestra aplicación. Agregamos el siguiente código a nuestro archivo build.gradle o a nuestro archivo POM.

Gradle

Añada lo siguiente al archivo build.gradle archivo.

repositories {
    mavenCentral()
}

dependencies {
    implementation 'com.vonage:server-sdk:8.20.1'
}

Maven

Añade lo siguiente al archivo POM de nuestro proyecto.

<dependency>
    <groupid>com.vonage</groupid>
    <artifactid>server-sdk</artifactid>
    <version>8.20.1</version>
</dependency>

Resumen de la aplicación

Ahora que hemos terminado la configuración, podemos sumergirnos en el desarrollo. Esta autenticación de inicio de sesión OTP con JWT se puede completar en tres pasos:

  1. Creación de JWT

  2. Filtrar solicitudes de usuarios

  3. Acceder a las funciones de la API

Creación de JWT

Utilizaremos un mecanismo de autorización basado en tokens en el que, una vez que el usuario se autentique correctamente, generaremos un nuevo token con un periodo de caducidad y se lo devolveremos al usuario.

El usuario tendrá que pasar este token en cada solicitud para demostrar su identidad y poder seguir accediendo a las aplicaciones que necesiten autorización.

Filtrar solicitudes de usuarios

Cada vez que recibamos un token en la petición, debemos verificarlo y decirle a Spring Security que el usuario está autorizado y puede acceder a los endpoints restringidos. Necesitaremos un filtro para cada petición.

Acceder a las funciones de la API

Utilizaremos tres rutas API diferentes para completar la autenticación. Cada una sirve a un propósito único:

  • Obtener el número de teléfono del usuario para enviar el OTP

  • Verify the OTP and return the JWT to the user

  • Punto final general para las pruebas

Veamos cómo se puede aplicar cada una de ellas por separado.

Gestión del cifrado JWT

JWT es una combinación de dos métodos de cifrado diferentes creados mediante JWS y JWE, que pueden cifrarse utilizando una clave simétrica CLAVE_SECRETA con una carga útil.

La carga útil puede contener datos sensibles que podemos utilizar para validar al usuario (por ejemplo, fecha de caducidad, detalles del usuario, etc.)

Utilizando la misma SECRET_KEYpodemos descifrar el token para obtener la carga útil y utilizarla cuando sea necesario.

Para manejar JWT, utilizaremos el paquete io.jsonwebtoken::jjwt paquete.

dependencies {
    implementation 'io.jsonwebtoken:jjwt:0.12.6'
}

Crear una JWTUtils clase dentro del paquete util y añade el siguiente código.

package com.example.vonage.auth.utils;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.cglib.core.internal.Function;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

@Component
public class JWTUtil {
	private final String SECRET_KEY = "secret key";

	public String extractIdentifier(String token) {
    	return extractClaim(token, Claims::getSubject);
	}

	public Date extractExpiration(String token){
    	return extractClaim(token, Claims::getExpiration);
	}

	public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
    	final Claims claims = extractAllClaims(token);
    	return claimsResolver.apply(claims);
	}

	private Claims extractAllClaims(String token){
    	return Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token).getBody();
	}

	private Boolean isTokenExpired(String token){
    	return extractExpiration(token).before(new Date());
	}

	public String generateToken(String details) {
    	Map<String, Object> claims = new HashMap<>();
    	return createToken(claims, details);
	}

	private String createToken(Map<String, Object> claims, String subject) {
    	long time = System.currentTimeMillis();
    	long expiry = Duration.ofDays(10).toMillis();
    	return Jwts.builder()
            	.setClaims(claims)
            	.setSubject(subject)
            	.setIssuedAt(new Date(time))
            	.setExpiration(new Date(time + expiry))
            	.signWith(SignatureAlgorithm.HS256, SECRET_KEY)
            	.compact();
	}

	public Boolean validateToken(String token, String identifier) {
    	final String phoneNumber = extractIdentifier(token);
    	return phoneNumber.equals(identifier) && !isTokenExpired(token);
	}
}

Contiene toda la lógica relativa al JWT, que puede utilizarse para crear un nuevo token, validar el token y obtener identificadores del token para validar al usuario, etc.

Aquí sustituimos la SECRET_KEY por cualquier secreto compartido y acceder a él desde los archivos de propiedades de la aplicación en lugar de mantenerlo codificado.

Además, al generar el token, pasamos los detalles (número de teléfono) como una cadena, pero puedes almacenar cualquier objeto y extraerlo.

Hemos mantenido la validación extremadamente simple, sólo estamos almacenando el número de teléfono en el token, y el mismo se utilizará para la autorización cada vez.

En el método validateToken comparamos los números de teléfono y comprobamos si el token ha caducado.

La fecha de caducidad del token es de diez días Duration.ofDays(10).toMillis() desde el momento de la creación del token.

Añadir filtro para autenticar un usuario y establecer el contexto

Cada vez que recibamos cualquier petición que requiera autorización, tendremos que validar el token y establecer el contexto de que el usuario está autenticado.

Añadiremos un nuevo filtro que ampliará el filtro OncePerRequestFilter- como su nombre indica; este filtro se ejecutará una vez por cada solicitud.

Cree una nueva clase Java llamada JWTFilter dentro del paquete filters y añade el siguiente código.

Esto contendrá la lógica para extraer el token de la solicitud y validarlo. Si está autorizado, establece el contexto en el que el usuario está autenticado.

package com.example.vonage.auth.filters;

import com.example.vonage.auth.utils.JWTUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.annotation.Order;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
public class JWTFilter extends OncePerRequestFilter {
    @Autowired
    JWTUtil jwtUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        final String authorizationHeader = request.getHeader("Authorization");
        String jwt = null, identifier = null;
        if (authorizationHeader != null && authorizationHeader.startsWith("Bearer")) {
            jwt = authorizationHeader.substring(7);
            identifier = jwtUtil.extractIdentifier(jwt);
        }
        if (identifier != null && jwtUtil.validateToken(jwt, identifier) && SecurityContextHolder.getContext().getAuthentication() == null) {
            AuthenticationFilter apiToken = new AuthenticationFilter("abc", "xyz", AuthorityUtils.createAuthorityList());
            SecurityContextHolder.getContext().setAuthentication(apiToken);
        }

        filterChain.doFilter(request, response);
    }
}

De cada solicitud, obtenemos la cabecera Authorization y extraemos el token después del texto Bearerpor lo que obtenemos la subcadena después de los siete primeros caracteres (incluyendo un espacio después de la palabra Bearer).

Una vez que tenemos el token, extraemos el identificador para su validación.

Para la validación, estamos probando si el usuario ya está autenticado o no, que no será como estamos teniendo un SESIÓN STATELESS y si el identificador es el mismo y si el token no ha caducado. La sesión es sin estado porque no estamos manteniendo ninguna sesión ya que esta es una autorización basada en token. Esta configuración se actualizará en la seguridad de primavera.

Porque sólo hemos utilizado el número de teléfono como identificador, no hay comprobación cruzada. Podemos hacerlo más seguro almacenando el correo electrónico del usuario o cualquier objeto único, y luego utilizando el número de teléfono para obtener el mismo de la base de datos y realizar la verificación.

Para obtener el contexto de autenticación, hemos ampliado el archivo AbstractAuthenticationToken y hemos proporcionado una clave y un secreto únicos.

AuthenticationFilter apiToken = new AuthenticationFilter("abc", "xyz", AuthorityUtils.createAuthorityList());

SecurityContextHolder.getContext().setAuthentication(apiToken);

abc y xyz pueden sustituirse por un par identificativo único como phone number y email.

Cree una nueva clase Java denominada AuthenticationFilter dentro del paquete filters para obtener el contexto Autenticado.

package com.example.vonage.auth.filters;

import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.Transient;
import java.util.Collection;

@Transient
public class AuthenticationFilter extends AbstractAuthenticationToken {
    private String apiKey, keySecret;

    /**
    * Creates a token with the supplied array of authorities.
    *
    * @param authorities the collection of <tt>GrantedAuthority</tt>s for the principal
    *                	represented by this authentication object.
    */
    public AuthenticationFilter(String apiKey, String keySecret, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.apiKey = apiKey;
        this.keySecret = keySecret;
        setAuthenticated(true);
    }

    @Override
    public Object getCredentials() {
        return keySecret;
    }

    @Override
    public Object getPrincipal() {
        return apiKey;
    }
}

Una vez realizada la autorización, seguiremos transmitiendo la solicitud filterChain.doFilter(request, response);

¡Nuestro código para el filtro está listo! Ahora necesitamos usarlo para cada petición excepto para el login.

Actualicemos la configuración de Spring Security para que se encargue de lo mismo.

Cree una nueva clase Java denominada WebSecurityConfig dentro del paquete config y añade el siguiente código.

package com.example.vonage.auth.config;

import com.example.vonage.auth.error.AuthError;
import com.example.vonage.auth.filters.JWTFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    AuthError authErrorHandler;

    @Autowired
    JWTFilter jwtFilter;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.cors().and().csrf().disable()
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and().authorizeRequests(configurer ->
                configurer.antMatchers("/api/login/**").permitAll().anyRequest().authenticated()
            ).exceptionHandling().authenticationEntryPoint(authErrorHandler);
    }
}

Aquí hemos configurado los parámetros cors y csrf para que la API funcione y hemos añadido nuestro jwtFilter antes de cualquier filtro de seguridad interno de Spring.

UsernamePasswordAuthenticationFilter.class es el primer filtro en prioridad. Pero antes de esto nuestro filtro se ejecuta y establece el contexto. Por lo tanto, no se requieren más comprobaciones y la solicitud se sigue procesando.

También hemos configurado la sesión para que sea sin estado .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) ya que estamos utilizando token para la autorización.

Todas las solicitudes de autenticación se omiten del control de seguridad "/api/login/**".

Al final, hemos añadido una clase de error común para manejar el error y proporcionar una respuesta personalizada .exceptionHandling().authenticationEntryPoint(authErrorHandler);.

Cree una clase Java denominada AuthError dentro del paquete error y añade el siguiente código.

package com.example.vonage.auth.error;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.server.ServerAuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.Serializable;

@Component
public class AuthError implements AuthenticationEntryPoint, Serializable {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        ObjectMapper mapper = new ObjectMapper();
        String responseMsg = mapper.writeValueAsString("Unathorized User");
        response.getWriter().write(responseMsg);
        response.setContentType("application/json");
    }
}

Definir controladores para las rutas restringidas y no restringidas

Nuestro filtro y token de validación están ahora en su lugar. Vamos a escuchar la solicitud y autenticar al usuario.

Servicios

Cree una clase java llamada 'AuthService' en el paquete services paquete.

package com.example.vonage.auth.services;

import com.example.vonage.auth.utils.JWTUtil;
import com.vonage.client.VonageClient;
import com.vonage.client.verify.CheckResponse;
import com.vonage.client.verify.VerifyResponse;
import com.vonage.client.verify.VerifyStatus;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class AuthService {
    @Autowired
    JWTUtil jwtUtil;

    final VonageClient client = VonageClient.builder()
        .apiKey("7ed*****")
        .apiSecret("W**u*E*VlaWe****")
        .build();

    public String init(String identifier) {
        VerifyResponse response = client.getVerifyClient().verify("91900*******", "Vonage");
        if (response.getStatus() == VerifyStatus.OK) {
            return response.getRequestId();
        }
        else {
            return "ERROR! " + response.getStatus() + " " + response.getErrorText();
        }
    }

    public String verify(String identifier, String request_id, String otp) {
        CheckResponse response = client.getVerifyClient().check(request_id, otp);
        if (response.getStatus() == VerifyStatus.OK) {
            return jwtUtil.generateToken(identifier);
        }
        else {
            return "Verification failed: " + response.getErrorText();
        }
    }
}

AuthService tendrá dos métodos: init y Verify.

init acepta el número de teléfono como parámetro y envía la OTP a ese número de teléfono. La respuesta devolverá un request_id que se utilizará con la OTP para la validación.

verify aceptará el número de teléfono, el request_id y el OTP como parámetros y verificará el request_id y el OTP. Una vez verificados, utilizará el número de teléfono como identificador, generará un nuevo token y devolverá la respuesta.

Controladores

Se puede acceder a una aplicación a través de puntos finales que se ponen a disposición del usuario. Los puntos finales pueden ser públicos, privados o protegidos. En nuestro caso, la API de inicio de sesión estará disponible públicamente, mientras que todas las demás API están protegidas y requieren la autenticación del usuario para acceder a ellas.

Autenticación (inicio de sesión)

No se necesita autorización para acceder a estas rutas.

Cree una clase Java llamada 'Auth' dentro del paquete controllers y añada el siguiente código.

package com.example.vonage.auth.controllers;

import com.example.vonage.auth.services.AuthService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.constraints.NotBlank;

@Validated
@RestController
@RequestMapping("api/login")
public class Auth {

    @Autowired
    AuthService authService;

    @PostMapping(path="/init", consumes = {MediaType.APPLICATION_FORM_URLENCODED_VALUE})
    public String init(@NotBlank String identifier){
        return authService.sendOtp(identifier);
    }

    @PostMapping(path="/verify", consumes = {MediaType.APPLICATION_FORM_URLENCODED_VALUE})
    public String verify(@NotBlank String identifier, @NotBlank String request_id, @NotBlank String otp) {
        return authService.verify(identifier, request_id, otp);
    }
}

Otros (restringidos)

Se requiere autorización para acceder a esta ruta.

Crea una clase Java llamada 'Hola' dentro del paquete controllers y añade el siguiente código.

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Validated
@RestController
@RequestMapping("api/")
public class Hello {
    
    @GetMapping(path="/hello")
    public String hello(){
        return "Hello world";
    }
}

Pruebas

Aquí tenemos tres puntos finales de API.

/api/hello sin autorización

Si no hay Authorization o se proporciona un token no válido, se producirá un error, unauthorized user.

Without Authorization

/api/login/init

Acepta el número de teléfono en formato URL codificado y devuelve el identificador de solicitud después de enviar la OTP.

Init the OTP request

/api/login/verify

Acepta el número de teléfono, el identificador de solicitud y la OTP en formato de URL codificada y devuelve el token JWT después de enviar la OTP.

Verify the OTP

/api/hello con autorización

En la cabecera de la solicitud, pase

Authorization : Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI5MDA0NzU4NTM0IiwiZXhwIjoxNjYzOTYxMzIwLCJpYXQiOjE2NjM5MjUzMjB9.fY2536Zuz2KNzinZvWU5CNZ6K1p_wOu0pkEbwqP8gbg

With Authorization

Conclusión

Ahora que hemos creado un flujo de inicio de sesión en dos pasos con número de teléfono y OTP, nuestro próximo paso podría ser agregar autenticación de dos factores en la que podamos volver a verificar al mismo usuario realizando una llamada al número de teléfono mediante la Voice API de Vonage.

Vonage dispone de un interesante conjunto de API disponibles que se pueden utilizar para llevar tu producto al siguiente nivel y ofrecer una mejor experiencia de usuario.

La participación de la comunidad siempre es bienvenida. Únete a Vonage en GitHub para ver ejemplos de código y en Slack de la comunidad para consultas en cualquier momento. Envíanos un tweet y cuéntanos el problema interesante que has resuelto con la API de Vonage.

También puede ponerse en contacto conmigo en Twitter o a través de mi blog learnersbucket.com.

Compartir:

https://a.storyblok.com/f/270183/400x533/ed2a75e7a4/prashant-yadav.png
Prashant YadavDesarrollador Destacado Autor

Ingeniero frontend senior con sede en Mumbai, India. Trabajo en el desarrollo fullstack con React y Spring boot para la Fintech más valorada de la India. También el fundador de learnersbucket.com donde escribo sobre guías de preparación de la entrevista de JavaScript.