REST Security con JWT usando Java y Spring Security

Publicado: 2022-03-11

Seguridad

La seguridad es enemiga de la comodidad y viceversa. Esta afirmación es válida para cualquier sistema, virtual o real, desde la entrada física de la casa hasta las plataformas de banca web. Los ingenieros están constantemente tratando de encontrar el equilibrio adecuado para el caso de uso dado, inclinándose hacia un lado o hacia el otro. Por lo general, cuando aparece una nueva amenaza, nos movemos hacia la seguridad y nos alejamos de la comodidad. Luego, vemos si podemos recuperar alguna comodidad perdida sin reducir demasiado la seguridad. Además, este círculo vicioso continúa para siempre.

tutorial de seguridad de primavera: ilustración de seguridad frente a comodidad

La seguridad es enemiga de la comodidad y viceversa.
Pío

Tratemos de examinar el estado de la seguridad REST hoy, utilizando un sencillo tutorial de seguridad de Spring para demostrarlo en acción.

Los servicios REST (que significa Transferencia de estado representacional) comenzaron como un enfoque extremadamente simplificado de los servicios web que tenían especificaciones enormes y formatos engorrosos, como WSDL para describir el servicio o SOAP para especificar el formato del mensaje. En REST, no tenemos ninguno de esos. Podemos describir el servicio REST en un archivo de texto sin formato y usar cualquier formato de mensaje que queramos, como JSON, XML o incluso texto sin formato nuevamente. El enfoque simplificado también se aplicó a la seguridad de los servicios REST; ningún estándar definido impone una forma particular de autenticar a los usuarios.

Aunque los servicios REST no tienen muchas especificaciones, una importante es la falta de estado. Significa que el servidor no mantiene ningún estado del cliente, con las sesiones como un buen ejemplo. Así, el servidor responde a cada petición como si fuera la primera que hace el cliente. Sin embargo, incluso ahora, muchas implementaciones todavía utilizan la autenticación basada en cookies, que se hereda del diseño arquitectónico estándar del sitio web. El enfoque sin estado de REST hace que las cookies de sesión sean inapropiadas desde el punto de vista de la seguridad, pero, sin embargo, todavía se usan ampliamente. Además de ignorar la apatridia requerida, el enfoque simplificado se presentó como una compensación de seguridad esperada. En comparación con el estándar WS-Security utilizado para los servicios web, es mucho más fácil crear y consumir servicios REST, por lo que la comodidad se disparó. La compensación es una seguridad bastante escasa; el secuestro de sesiones y la falsificación de solicitudes entre sitios (XSRF) son los problemas de seguridad más comunes.

Al tratar de deshacerse de las sesiones de cliente del servidor, se han utilizado otros métodos ocasionalmente, como la autenticación HTTP básica o Digest. Ambos usan un encabezado de Authorization para transmitir las credenciales de usuario, con algo de codificación (HTTP Basic) o encriptación (HTTP Digest) agregada. Por supuesto, tenían las mismas fallas que se encuentran en los sitios web: HTTP Basic tenía que usarse sobre HTTPS, ya que el nombre de usuario y la contraseña se envían en codificación base64 fácilmente reversible, y HTTP Digest forzaba el uso de hash MD5 obsoleto que demostró ser inseguro.

Finalmente, algunas implementaciones usaron tokens arbitrarios para autenticar a los clientes. Esta opción parece ser la mejor que tenemos, por ahora. Si se implementa correctamente, soluciona todos los problemas de seguridad de HTTP Basic, HTTP Digest o cookies de sesión, es fácil de usar y sigue el patrón sin estado.

Sin embargo, con tokens tan arbitrarios, hay poco estándar involucrado. Cada proveedor de servicios tenía su idea de qué poner en el token y cómo codificarlo o encriptarlo. El consumo de servicios de diferentes proveedores requería tiempo de configuración adicional, solo para adaptarse al formato de token específico utilizado. Los otros métodos, por otro lado (cookie de sesión, HTTP Basic y HTTP Digest) son bien conocidos por los desarrolladores, y casi todos los navegadores en todos los dispositivos funcionan con ellos listos para usar. Los marcos y los lenguajes están listos para estos métodos, con funciones integradas para manejar cada uno sin problemas.

Autenticación JWT

JWT (abreviado de JSON Web Token) es la estandarización que falta para usar tokens para autenticarse en la web en general, no solo para los servicios REST. Actualmente, se encuentra en estado de borrador como RFC 7519. Es robusto y puede transportar mucha información, pero sigue siendo fácil de usar a pesar de que su tamaño es relativamente pequeño. Como cualquier otro token, JWT se puede usar para pasar la identidad de usuarios autenticados entre un proveedor de identidad y un proveedor de servicios (que no son necesariamente los mismos sistemas). También puede llevar todos los reclamos del usuario, como datos de autorización, por lo que el proveedor de servicios no necesita ingresar a la base de datos o sistemas externos para verificar los roles y permisos de los usuarios para cada solicitud; esos datos se extraen del token.

Así es como la seguridad JWT está diseñada para funcionar:

Ilustración de flujo de Java JWT

  • Los clientes inician sesión enviando sus credenciales al proveedor de identidad.
  • El proveedor de identidad verifica las credenciales; si todo está bien, recupera los datos del usuario, genera un JWT que contiene los detalles del usuario y los permisos que se utilizarán para acceder a los servicios, y también establece la caducidad del JWT (que puede ser ilimitado).
  • El proveedor de identidad firma y, si es necesario, cifra el JWT y lo envía al cliente como respuesta a la solicitud inicial con credenciales.
  • El cliente almacena el JWT por un tiempo limitado o ilimitado, según el vencimiento establecido por el proveedor de identidad.
  • El cliente envía el JWT almacenado en un encabezado de autorización para cada solicitud al proveedor de servicios.
  • Para cada solicitud, el proveedor de servicios toma el JWT del encabezado de Authorization y lo descifra, si es necesario, valida la firma y, si todo está bien, extrae los datos y permisos del usuario. Basándose únicamente en estos datos, y nuevamente sin buscar más detalles en la base de datos o contactar al proveedor de identidad, puede aceptar o rechazar la solicitud del cliente. El único requisito es que los proveedores de servicios e identidad tengan un acuerdo sobre el cifrado para que el servicio pueda verificar la firma o incluso descifrar qué identidad fue cifrada.

Este flujo permite una gran flexibilidad mientras mantiene las cosas seguras y fáciles de desarrollar. Al usar este enfoque, es fácil agregar nuevos nodos de servidor al clúster del proveedor de servicios, inicializándolos con solo la capacidad de verificar la firma y descifrar los tokens al proporcionarles una clave secreta compartida. No se requiere replicación de sesiones, sincronización de bases de datos o comunicación entre nodos. DESCANSO en todo su esplendor.

La principal diferencia entre JWT y otros tokens arbitrarios es la estandarización del contenido del token. Otro enfoque recomendado es enviar el token JWT en el encabezado de Authorization utilizando el esquema Bearer. El contenido del encabezado debería verse así:

 Authorization: Bearer <token>

Implementación de seguridad REST

Para que los servicios REST funcionen como se espera, necesitamos un enfoque de autorización ligeramente diferente en comparación con los sitios web clásicos de varias páginas.

En lugar de activar el proceso de autenticación redirigiendo a una página de inicio de sesión cuando un cliente solicita un recurso seguro, el servidor REST autentica todas las solicitudes utilizando los datos disponibles en la solicitud misma, el token JWT en este caso. Si tal autenticación falla, la redirección no tiene sentido. La API REST simplemente envía una respuesta de código HTTP 401 (no autorizado) y los clientes deben saber qué hacer; por ejemplo, un navegador mostrará un div dinámico para permitir que el usuario proporcione el nombre de usuario y la contraseña.

Por otro lado, después de una autenticación exitosa en sitios web clásicos de varias páginas, el usuario es redirigido utilizando el código HTTP 301 (Movido permanentemente), generalmente a una página de inicio o, mejor aún, a la página que el usuario solicitó inicialmente que activó el proceso de autenticación. Con REST, nuevamente esto no tiene sentido. En su lugar, simplemente continuaríamos con la ejecución de la solicitud como si el recurso no estuviera protegido en absoluto, devolveríamos el código HTTP 200 (OK) y el cuerpo de respuesta esperado.

Ejemplo de seguridad de primavera

Spring REST Seguridad con JWT y Java

Ahora, veamos cómo podemos implementar la API REST basada en el token JWT usando Java y Spring, mientras intentamos reutilizar el comportamiento predeterminado de Spring Security donde podamos.

Como era de esperar, el marco Spring Security viene con muchas clases listas para conectar que se ocupan de los mecanismos de autorización "antiguos": cookies de sesión, HTTP Basic y HTTP Digest. Sin embargo, carece del soporte nativo para JWT, y necesitamos ensuciarse las manos para que funcione. Para obtener una descripción más detallada, debe consultar la documentación oficial de Spring Security.

Ahora, comencemos con la definición habitual del filtro Spring Security en web.xml :

 <filter> <filter-name>springSecurityFilterChain</filter-name> <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class> </filter> <filter-mapping> <filter-name>springSecurityFilterChain</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>

Tenga en cuenta que el nombre del filtro de Spring Security debe ser exactamente springSecurityFilterChain para que el resto de la configuración de Spring funcione de inmediato.

Luego viene la declaración XML de Spring beans relacionada con la seguridad. Para simplificar el XML, estableceremos el espacio de nombres predeterminado en security agregando xmlns="http://www.springframework.org/schema/security" al elemento XML raíz. El resto del XML se ve así:

 <global-method-security pre-post-annotations="enabled" /> (1) <http pattern="/api/login" security="none"/> (2) <http pattern="/api/signup" security="none"/> <http pattern="/api/**" entry-point-ref="restAuthenticationEntryPoint" create-session="stateless"> (3) <csrf disabled="true"/> (4) <custom-filter before="FORM_LOGIN_FILTER" ref="jwtAuthenticationFilter"/> (5) </http> <beans:bean class="com.toptal.travelplanner.security.JwtAuthenticationFilter"> (6) <beans:property name="authenticationManager" ref="authenticationManager" /> <beans:property name="authenticationSuccessHandler" ref="jwtAuthenticationSuccessHandler" /> (7) </beans:bean> <authentication-manager alias="authenticationManager"> <authentication-provider ref="jwtAuthenticationProvider" /> (8) </authentication-manager>
  • (1) En esta línea, activamos las @PreFilter , @PreAuthorize , @PostFilter , @PostAuthorize en cualquier spring beans en el contexto.
  • (2) Definimos los puntos finales de inicio de sesión y registro para omitir la seguridad; incluso "anónimo" debería poder hacer estas dos operaciones.
  • (3) A continuación, definimos la cadena de filtros que se aplica a todas las solicitudes mientras agregamos dos configuraciones importantes: la referencia del punto de entrada y la configuración de la creación de la sesión como stateless (no queremos que la sesión se cree por motivos de seguridad, ya que usamos tokens para cada solicitud) .
  • (4) No necesitamos protección csrf porque nuestros tokens son inmunes a ella.
  • (5) A continuación, conectamos nuestro filtro de autenticación especial dentro de la cadena de filtros predefinida de Spring, justo antes del filtro de inicio de sesión del formulario.
  • (6) Este bean es la declaración de nuestro filtro de autenticación; dado que está extendiendo AbstractAuthenticationProcessingFilter de Spring, debemos declararlo en XML para conectar sus propiedades (la conexión automática no funciona aquí). Más adelante explicaremos qué hace el filtro.
  • (7) El controlador de éxito predeterminado de AbstractAuthenticationProcessingFilter no es lo suficientemente bueno para propósitos de REST porque redirige al usuario a una página de éxito; es por eso que establecemos el nuestro aquí.
  • (8) Nuestro filtro utiliza la declaración del proveedor creada por el administrador de authenticationManager para autenticar a los usuarios.

Ahora veamos cómo implementamos las clases específicas declaradas en el XML anterior. Tenga en cuenta que Spring los conectará por nosotros. Comenzamos con los más simples.

RestAuthenticationEntryPoint.java

 public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { // This is invoked when user tries to access a secured REST resource without supplying any credentials // We should just send a 401 Unauthorized response because there is no 'login page' to redirect to response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"); } }

Como se explicó anteriormente, esta clase solo devuelve el código HTTP 401 (no autorizado) cuando falla la autenticación, anulando la redirección predeterminada de Spring.

JwtAuthenticationSuccessHandler.java

 public class JwtAuthenticationSuccessHandler implements AuthenticationSuccessHandler { @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { // We do not need to do anything extra on REST authentication success, because there is no page to redirect to } }

Esta anulación simple elimina el comportamiento predeterminado de una autenticación exitosa (redireccionamiento a la página de inicio o cualquier otra página solicitada por el usuario). Si se pregunta por qué no necesitamos anular el AuthenticationFailureHandler , es porque la implementación predeterminada no redirigirá a ningún lado si su URL de redirección no está configurada, por lo que simplemente evitamos configurar la URL, lo cual es lo suficientemente bueno.

JwtAuthenticationFilter.java

 public class JwtAuthenticationFilter extends AbstractAuthenticationProcessingFilter { public JwtAuthenticationFilter() { super("/**"); } @Override protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) { return true; } @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { String header = request.getHeader("Authorization"); if (header == null || !header.startsWith("Bearer ")) { throw new JwtTokenMissingException("No JWT token found in request headers"); } String authToken = header.substring(7); JwtAuthenticationToken authRequest = new JwtAuthenticationToken(authToken); return getAuthenticationManager().authenticate(authRequest); } @Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { super.successfulAuthentication(request, response, chain, authResult); // As this authentication is in HTTP header, after success we need to continue the request normally // and return the response as if the resource was not secured at all chain.doFilter(request, response); } }

Esta clase es el punto de entrada de nuestro proceso de autenticación JWT; el filtro extrae el token JWT de los encabezados de solicitud y delega la autenticación al AuthenticationManager inyectado. Si no se encuentra el token, se lanza una excepción que detiene el procesamiento de la solicitud. También necesitamos una anulación para una autenticación exitosa porque el flujo de Spring predeterminado detendría la cadena de filtros y continuaría con una redirección. Tenga en cuenta que necesitamos que la cadena se ejecute por completo, incluida la generación de la respuesta, como se explicó anteriormente.

JwtAuthenticationProvider.java

 public class JwtAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider { @Autowired private JwtUtil jwtUtil; @Override public boolean supports(Class<?> authentication) { return (JwtAuthenticationToken.class.isAssignableFrom(authentication)); } @Override protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { } @Override protected UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { JwtAuthenticationToken jwtAuthenticationToken = (JwtAuthenticationToken) authentication; String token = jwtAuthenticationToken.getToken(); User parsedUser = jwtUtil.parseToken(token); if (parsedUser == null) { throw new JwtTokenMalformedException("JWT token is not valid"); } List<GrantedAuthority> authorityList = AuthorityUtils.commaSeparatedStringToAuthorityList(parsedUser.getRole()); return new AuthenticatedUser(parsedUser.getId(), parsedUser.getUsername(), token, authorityList); } }

En esta clase, estamos usando el AuthenticationManager predeterminado de Spring, pero lo inyectamos con nuestro propio AuthenticationProvider que realiza el proceso de autenticación real. Para implementar esto, extendemos AbstractUserDetailsAuthenticationProvider , que solo requiere que UserDetails en función de la solicitud de autenticación, en nuestro caso, el token JWT envuelto en la clase JwtAuthenticationToken . Si el token no es válido, lanzamos una excepción. Sin embargo, si es válido y el descifrado por JwtUtil es exitoso, extraemos los detalles del usuario (veremos exactamente cómo en la clase JwtUtil ), sin acceder a la base de datos en absoluto. Toda la información sobre el usuario, incluidas sus funciones, está contenida en el propio token.

JwtUtil.java

 public class JwtUtil { @Value("${jwt.secret}") private String secret; /** * Tries to parse specified String as a JWT token. If successful, returns User object with username, id and role prefilled (extracted from token). * If unsuccessful (token is invalid or not containing all required user properties), simply returns null. * * @param token the JWT token to parse * @return the User object extracted from specified token or null if a token is invalid. */ public User parseToken(String token) { try { Claims body = Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); User u = new User(); u.setUsername(body.getSubject()); u.setId(Long.parseLong((String) body.get("userId"))); u.setRole((String) body.get("role")); return u; } catch (JwtException | ClassCastException e) { return null; } } /** * Generates a JWT token containing username as subject, and userId and role as additional claims. These properties are taken from the specified * User object. Tokens validity is infinite. * * @param u the user for which the token will be generated * @return the JWT token */ public String generateToken(User u) { Claims claims = Jwts.claims().setSubject(u.getUsername()); claims.put("userId", u.getId() + ""); claims.put("role", u.getRole()); return Jwts.builder() .setClaims(claims) .signWith(SignatureAlgorithm.HS512, secret) .compact(); } }

Finalmente, la clase JwtUtil está a cargo de analizar el token en el objeto User y generar el token a partir del objeto User . Es sencillo ya que utiliza la biblioteca jjwt para hacer todo el trabajo de JWT. En nuestro ejemplo, simplemente almacenamos el nombre de usuario, la ID de usuario y los roles de usuario en el token. También podríamos almacenar más cosas arbitrarias y agregar más funciones de seguridad, como la caducidad del token. El análisis del token se usa en AuthenticationProvider como se muestra arriba. El método generateToken() se llama desde los servicios REST de inicio de sesión y registro, que no son seguros y no activarán ninguna verificación de seguridad ni requerirán que un token esté presente en la solicitud. Al final, genera el token que se devolverá a los clientes, en función del usuario.

Conclusión

Aunque los antiguos enfoques de seguridad estandarizados (cookie de sesión, HTTP Basic y HTTP Digest) también funcionarán con los servicios REST, todos tienen problemas que sería bueno evitar mediante el uso de un mejor estándar. JWT llega justo a tiempo para salvar el día y, lo que es más importante, está muy cerca de convertirse en un estándar IETF.

La principal fortaleza de JWT es manejar la autenticación de usuario sin estado y, por lo tanto, escalable, mientras mantiene todo seguro con estándares de criptografía actualizados. El almacenamiento de notificaciones (permisos y roles de usuario) en el propio token crea enormes beneficios en las arquitecturas de sistemas distribuidos donde el servidor que emite la solicitud no tiene acceso a la fuente de datos de autenticación.