Volver |

JWT

JSON Web Tokens

Es un estándar abierto (RFC 7519) que define una forma compacta de transmitir información entre dos partes, como un objeto JSON. La información puede ser verificada porque está firmada digitalmente.

Los JSON Web Tokens son útiles para:

  • Autenticación: cuando un usuario se loguea con sus credenciales, se le devuelve un token id, y si se siguen las especificaciones de OpenID, ese token id es siempre un JWT. 

  • Autorización: luego de que el usuario se loguee, cada petición subsiguiente incluirá el JWT, permitiendo el acceso a servicios y recursos que el usuario tenga permiso con dicho token.

  • Intercambio de información: el usuario puede estar seguro de que quien envía la información es quien dice ser, según la firma del token. La firma se calcula usando la cabecera y el cuerpo del token, de manera tal de verificar que el contenido no ha sido manipulado.

Ventajas:

  • Debido a su tamaño relativamente pequeño, pueden ser enviados a través de una URL, a través de un parámetro en POST, o dentro de una cabecera HTTP, y se transmiten rápido.

  • Evita tener que consultar la base de datos para acceder a los datos de la entidad más de una vez. Quien recibe el token tampoco necesita realizar llamadas al servidor para consultar el token.

Funcionamiento básico

  1. El usuario se loguea en la aplicación.

  2. El servidor le devuelve un JWT.

  3. En cada petición subsiguiente hacia el servidor, en las que el usuario desee acceder a algún recurso, el browser enviará el token JWT (generalmente en la cabecera Authorization: Bearer <token>). Cada ruta a los recursos solicitados, validará el token que recibe de la petición, y luego permitirá el acceso a los recursos.

Estructura

Un JWT está compuesto por tres partes: headerpayload y signature (firma de los datos). Cada parte está codificada en base64url. Entonces cada elementos queda separado por un “ . “.

Para conformar dicho token, tanto el header (primera parte) como el payload (segunda parte) que originalmente están en formato JSON, se codifican en base64URL por separado y se concatenan con un punto. Luego, tomando el header y el payload se los firma con un algoritmo, utilizando una clave. Se puede utilizar HMAC y un secret, o RSA, ECDSA con par de claves pública/privada. De esta manera, el resultado es la tercera parte del token, codificada en base64URL y concatenada luego de la segunda parte, también separada con un punto.

  • Header

​​​​​Indica el tipo de token (JWT) y el algoritmo utilizado para firmarlo (HMAC SHA256 o RSA). Por ejemplo:

{

  "alg": "HS256",

  "typ": "JWT"

}

Si el token no posee cifrado, “alg” se setea a “none”, y este claim es el único atributo obligatorio para tokens no cifrados/firmados. “typ” indica el tipo del token en sí mismo, que es JWT. Este claim es opcional, al igual que “cty” (content type), utilizado para cuando existe otro JWT anidado. Por lo tanto para este último caso, “cty”:”JWT”.

  • Payload

​​​​​​Contiene los claims. Los claims son definiciones acerca de una de las partes involucradas, o un objeto. Algunos claims son definidos por el estándar, y otros por el usuario. Para esta segunda parte no hay claims obligatorios.

Tipos de claims

- Registered claims: no obligatorios sino recomendados. Ejemplo de ellos son:

exp (expiration): fecha y hora específica que indica a partir de qué momento el token se considera inválido. Algunas Implementaciones permiten que el token sea válido unos segundos luego de la fecha de expiración.

nbf (not before): fecha y hora a partir de las cuales el token se considera válido. 

iat (issued at): fecha y hora en la que el token fue emitido.

jti: identificador del token, para diferenciarlo de otros similares. Garantiza unicidad, evitando replays.

aud (audience).

- Public claims: pueden ser definidos por quienes utilizan el estándar (para evitar colisiones con claims existentes, deberían registrarse en  IANA JSON Web Token Registry o definirse como URI).

- Private claims: son claims personalizados, creados por usuarios, para compartir información entre partes, quienes se ponen de acuerdo en utilizarlos. Tener en cuenta que pueden existir colisiones.

Ejemplo de payload:

{

  "sub": "1234567890",

  "name": "John Doe",

  "admin": true

}

IMPORTANTE: Recordar que tanto el header como el payload, la información que contienen es pública, es decir, es legible para cualquiera. Por lo tanto no se recomienda colocar información sensible allí, a menos que esté cifrada.

  • Signature

Para conformar la tercera parte, se debe tomar el header codificado, el payload codificado, una clave, el algoritmo especificado en el header, y firmar todo. La signature se utiliza para verificar que el mensaje no haya cambiado durante el camino.

Ejemplo: si quisiéramos firmar con HMAC SHA256, la signature se creará de la siguiente manera:

HMACSHA256(   base64UrlEncode(header) + "." +  base64UrlEncode(payload), secret )

Entonces, un JWT quedará de la siguiente forma:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9

                                    .dyt0CoTl4WoVjAHI9Q_CwSKhl6d_9rhM3NrXuJttkao

 

El propósito de una firma es que una o más partes determinen la autenticidad de un JWT. Es decir, verificar y asegurar que los datos no han sido manipulados. Un aspecto importante de los JWT: se requiere verificar que todas las condiciones del header y payload sean válidas, para considerar el token como válido. Incluso si este token no cuenta con signature, se puede considerar válido (alg: none). Asimismo, un token con signature puede considerarse inválido por otras razones, no necesariamente será válido por simplemente poseer firma.

Se utiliza generalmente HMAC, con SHA-256, (HS256), según la especificación JWS. Otros algoritmos recomendados son:

  • RSASSA PKCS1 v1.5, usando SHA-256, (RS256)

  • ECDSA usando P-256 y SHA-256, (ES256)

La especificación del algoritmo en el header sería la siguiente en caso de utilizar HS256:

{ "alg": "HS256", "typ": "JWT" }

 

JWT no seguros

Un token con header como el siguiente:

{

    "alg": "none"

}

Y un payload definido de la siguiente manera:

{

  "sub": "user123",

  "session": "ch72gsb320000udocl363eofy",

  "name": "Pretty Name",

  "lastpage": "/views/settings"

}

No hay firma ni cifrado, por lo tanto el JWT consta de dos partes:

eyJhbGciOiJub25lIn0.

eyJzdWIiOiJ1c2VyMTIzIiwic2Vzc2lvbiI6ImNoNzJnc2IzMjAwMDB1ZG9jbDM2M2VvZnkiLCJuYW1lIjoiUHJldHR5IE5hbWUiLCJsYXN0cGFnZSI6Ii92aWV3cy9zZXR0aW5ncyJ9.

No habría problema en utilizar este JWT para construir una vista con los datos que contiene el payload, en caso de que el id de sesión sea difícil de adivinar. Se utilizaría en el caso de mostrar el nombre del usuario, sin tener que acceder al backend para consultarlo cada vez.

  • Creación de JWT no seguros

Para llegar a la representación compacta JSON del header y el payload, realizar lo siguiente:

  1. Tomar al header como un byte array de la representación en UTF-8. No es necesario minificar o quitar caracteres irrelevantes antes antes de codificarlo.

  2. Codificar el byte array en Base64URL, borrando los signos “=”.

  3.  Tomar el payload como un byte array de su representación en UTF-8. Aplica lo mismo que en 1) para los caracteres irrelevantes.

  4. Codificar el byte array en Base64URL, borrando signos “=”.

  5. Concatenar los strings resultantes, colocando primero el header, seguido de un “.”, y luego seguido del payload.

IMPORTANTE: Se debe validar tanto el header como el payload (respecto a la presencia de claims requeridos y su uso), antes de codificar.

Para obtener la representación de los datos en JSON, a partir de la forma serializada, realizar lo siguiente: 

  1. Encontrar el primer “.” . Tomar el string anterior al caracter.

  2. Decodificar utilizando Base64URL. El resultado que se obtiene corresponde al header.

  3. Tomar el segundo string luego del primer punto.

  4. Decodificar dicho string como en el punto 2). Se obtiene el payload.

 

Un ataque contra un token firmado consiste en modificar el header de manera tal de quitarle la firma (alg: none), es decir, se transforma en un token no seguro, como hemos visto anteriormente. El usuario debe asegurarse de validar el token de acuerdo a sus propios requerimientos.

 

Retomando los tokens con signature, un ejemplo sería el siguiente:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

Para decodificar las dos primeras partes se realiza el mismo procedimiento que para decodificar tokens inseguros. Se obtendrá:

{

"alg": "HS256",

"typ": "JWT"

}

{

"sub": "1234567890",

"name": "John Doe",

"admin": true

}

Utilizando HMAC para firmar el token, con JS:

const signature = base64(hmac(`${encodedHeader}.${encodedPayload}`,secret, sha256));

Utilizando algoritmo de clave pública para firmar el token:

const signature = base64(rsassa(`${encodedHeader}.${encodedPayload}`, privateKey, sha256));

  • HMAC (Keyed-Hash Message Authentication Code): combina un payload con un “secret” utilizando una función de hash criptográfica (SHA256). El resultado es un código de tamaño fijo (independientemente del tamaño del mensaje) que puede ser utilizado para verificar un mensaje únicamente si ambas partes (quien genera el código y quien lo verifica) conocen el secret. Es decir, el secret es compartido entre las dos partes. 

Recordar que un mismo mensaje siempre producirá la misma salida. 

Problema: en un escenario de uno a muchos, cualquiera de las partes puede verificar los mensajes como así también crear nuevos mensajes (conocen el secret). Un usuario legítimo podría modificar interceptar el mensaje y modificarlo sin que los demás se den cuenta, ya que al estar firmado con el secret se garantiza la autenticidad.

  • RSASSA: es una variación de RSA, adaptada para firmas. Recordar que al ser de clave pública, se poseen dos claves, una pública y una privada. En esta variación, la clave privada puede ser utilizada tanto para crear un mensaje firmado como para verificar su autenticidad. La clave pública, en cambio, puede utilizarse para verificar autenticidad únicamente.

A diferencia del escenario anterior, si un usuario que conoce la clave pública quisiera crear un mensaje firmado, no podría ya que no cuenta con la clave privada.

La criptografía de clave pública permite también que se puedan cifrar mensajes con la clave pública, de manera tal que el destinatario los descifre con su clave privada. Cualquiera que conozca la clave pública del destinatario, puede cifrar el mensaje con la misma y se garantiza confidencialidad ya que el único que puede descifrar el mensaje es él, que posee su clave privada (siempre y cuando la haya mantenido a buen resguardo).

Vulnerabilidad en librerías JWT

Algunas librerías tratan tokens firmados con "alg":"none" como si fueran tokens válidos con firma verificada. El problema es que cualquiera puede crear su propio token "firmado" con el payload que quiera, permitiendo el acceso arbitrariamente en algunos sistemas.

Se puede modificar el header, cambiando el algoritmo que posea el claim "alg" seteando "none" , y utilizando una signature vacía "". El token podría ser válido en un contexto en el que se acepten aquellos que posean "none".

Muchas implementaciones previenen este ataque. Si una clave secreta fue provista, entonces la verificación del token fallará para tokens que no utilizan algoritmo, como es el caso del párrafo anterior. Sin embargo, se puede controlar la elección del algoritmo...

Algunas librerías implementan lo siguiente:

# sometimes called "decode"

verify(string token, string verificationKey)

# returns payload if valid token, else throws an error

Si en el sistema se utiliza HMAC, verificationKey será el secret con el que se firmó el token (ya que HMAC utiliza la misma clave para firmar que para verificar). 

verify(clientToken, serverHMACSecretKey)

En sistemas que utilizan algoritmo asimétrico, verificationKey será la clave pública contra la cual el token será verificado.

verify(clientToken, serverRSAPublicKey)

Un atacante puede abusar de esto. Si el servidor espera un token firmado con RSA, pero recibe un token firmado con HMAC, pensará que la clave pública es el secret utilizado en HMAC.

Las claves secret en HMAC se deben mantener privadas, mientras que las claves públicas no. Esto significa que el atacante tiene acceso a la clave pública, y puede usarla para crear un token que el servidor aceptará. Por ejemplo:

forgedToken = sign(tokenPayload, 'HS256', serverRSAPublicKey)

Para que el ataque funcione, serverRSAPublicKey debe ser exactamente la misma que se utiliza en el servidor para verificar.

RECOMENDACIÓN: en el servidor se debería verificar qué algoritmo proviene del token. No es seguro permitir a los atacantes proveer ese valor.

verify(string token, string algorithm, string verificationKey)

Al utilizar las librerías, es importante asegurarse de que los tokens con tipo de signature diferente sean rechazados.

Firma y validación de tokens

A continuación se presentan ejemplos utilizando la librería jsonwebtoken.

HS256: HMAC + SHA-256

HMAC requiere de una clave compartida (secret).

const secret = 'my-secret';

const signed = jwt.sign(payload, secret, {algorithm: 'HS256',expiresIn: '5s' /

/ si no se especifica, el token no expirará.});

// verificación

const decoded = jwt.verify(signed, secret, {

// explicitar el algoritmo para evitar ataques

algorithms: ['HS256'],});

La librería utilizada de ejemplo verifica la validez del token basada en su firma y la fecha de expiración.

 

RS256: RSA + SHA256

La única diferencia con el método anterior, es la utilización de un par de claves pública y privada. Para la creación y gestión de claves se puede utilizar la librería OpenSSL.

//Generación de clave privada

openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048

//Derivación de la clave pública a partir de la privada

openssl rsa -pubout -in private_key.pem -out public_key.pem

El contenido de los archivos .pem puede ser copiado e incluido en la librería jsonwebtoken

 

const privateRsaKey = `<TU-CLAVE-PRIVADA>`; //la clave proviene de private_key.pem

const signed = jwt.sign(payload, privateRsaKey,

{algorithm: 'RS256',expiresIn: '5s'});

const publicRsaKey = `<TU-CLAVE-PÚBLICA>`; //proviene de public_key.pem

const decoded = jwt.verify(signed, publicRsaKey, {

algorithms: ['RS256'], });

Refresh tokens

Un refresh token es un token que permite obtener un access token. En general, deben durar mucho tiempo y deben ser almacenados de forma muy segura. Sin embargo, en una aplicación web esto se puede complicar. Se puede utilizar un algoritmo de Refresh Token Rotation (RTR). Básicamente significa que los refresh tokens son solo válidos por una única vez.

El RTR funciona así:

1. Cuando se utiliza un refresh token para obtener un access token, se obtiene un nuevo refresh token también.

2. Al parecer esto no protegería en nada, sin embargo, si se vuelve a usar el mismo refresh token se lo invalida a éste y a todos los refresh tokens generados a partir de él.

 

 

Referencias:

https://auth0.com/blog/refresh-tokens-what-are-they-and-when-to-use-them/

https://pragmaticwebsecurity.com/articles/oauthoidc/refresh-token-protection-implications.html

JSON Web Encryption (JWE)

JWE provee una forma de que los datos sean ilegibles ante terceros, mientras que JWS brinda un medio para validar los datos.

Existen dos esquemas:

- Esquema de secret compartido: todas las partes conocen la clave. Cada una puede cifrar y descifrar información.

- Esquema de clave pública/privada: la parte que posee la clave privada es la única que puede descrifrar el token. Quienes poseen la clave pública pueden cifrar, e introducir información. En contraste con JWS, quienes conocen la clave pública pueden verificar los datos, pero no pueden introducir nueva información, mientras que quienes poseen la clave privada pueden firmar y verificar tokens.

Es importante tener en cuenta que JWE no reemplaza a JWS sino que ambos métodos son complementarios.

Estructura de un JWT cifrado

eyJhbGciOiJSU0ExXzUiLCJlbmMiOiJBMTI4Q0JDLUhTMjU2In0.UGhIOguC7IuEvf_NPVaXsGMoLOmwvc1GyqlIKOK1nN94nHPoltGRhWhw7Zx0-kFm1NJn8LE9XShH59_i8J0PH5ZZyNfGy2xGdULU7sHNF6Gp2vPLgNZ__deLKxGHZ7PcHALUzoOegEI-8E66jX2E4zyJKx-YxzZIItRzC5hlRirb6Y5Cl_p-ko3YvkkysZIFNPccxRU7qve1WYPxqbb2Yw8kZqa2rMWI5ng8OtvzlV7elprCbuPhcCdZ6XDP0_F8rkXds2vE4X-ncOIM8hAYHHi29NX0mcKiRaD0-D-ljQTP-cFPgwCp6X-nZZd9OHBv-B3oWh2TbqmScqXMR4gp_A.AxY8DCtDaGlsbGljb3RoZQ.KDlTtXchhZTGufMYmOYGS4HffxPSUrfmqCHXaI9wOGY.9hH0vgRfYgPnAHOd8stkvw

En este caso, tenemos 5 elementos separados por puntos, y la información contenida en el token está codificada en base64url:

  1. Cabecera protegida: análoga al header JWS.
  2. Clave cifrada: una clave simétrica se utiliza para cifrar la información. Esta clave se deriva de la actual clave de cifrado especificada por el usuario. Generalmente se utiliza cifrado simétrico de la información, ya que computacionalmente es rápido. La clave simétrica asimismo es cifrada de manera asimétrica por la clave especificada por el usuario.
  3. Vector de inicialización: datos aleatorios que ciertos algoritmos de cifrado requieren.
  4. Datos cifrados (ciphertext): los datos que actualmente son cifrados.
  5. Etiqueta de autenticación: datos adicionales generados por los algoritmos, que pueden ser utilizados para validar el contenido del ciphertext contra manipulación.

IMPORTANTE: algunos algoritmos de cifrado procesan cualquier información que reciban. Si el ciphertext es modificado (incluso sin haber sido descrifrado), los algoritmos lo procesarán. La etiqueta de autenticación puede prevenir esto, actuando como firma.

 

Generar un JWT en Python3

A continuación se presenta un ejemplo de cómo generar un JWT firmado (JWS) en Python3 mediante el algoritmo HS256:

import hashlib
import hmac
header = """{"alg":"HS256","typ":"JWT"}"""
body = """{"sub":"1234567890","name":"John Doe","iat":1516239022}"""
b64_header = base64.urlsafe_b64encode(header.encode("UTF-8"))
b64_body = base64.urlsafe_b64encode(body.encode("UTF-8"))
b64_body = b64_body.rstrip(b"=")
data = b64_header + b"." + b64_body
h = hmac.new(b"clave", data, hashlib.sha256)
hs256 = base64.urlsafe_b64encode(h.digest())
hs256 = hs256.rstrip(b"=")
jwt = data + b"." + hs256

 

En este artículo participaron: