Skip to content

JWT

JWT se ha convertido en una de las formas principales de autenticación en la comunicación de servidores modernos, con las características de ser ligero y sin estado.

Introducción

JWT, nombre completo JSON Web Tokens, los detalles sobre JWT se pueden ver en jwt.io, es una forma abierta, segura y compacta de transmitir información entre dos partes usando objetos JSON como medio. Sus características son alta seguridad, prevención de alteración de contenido y bajo consumo.

Estructura

En el estándar RFC, JWT consta de las siguientes tres partes:

  • Header (Encabezado)
  • Payload (Carga útil)
  • Signature (Firma)

Luego, cada parte se separa con un punto ., formando finalmente una cadena, el formato es header.payload.signature, esta es la estructura estándar de un token JWT. A continuación se explicará la función de cada estructura.

TIP

Es importante tener en cuenta que base64 y base64URL no son el mismo tipo de codificación, este último es compatible con URL web y las ha escapado.

Encabezado

El encabezado solo declara alguna información básica, generalmente consta de dos partes: el tipo de token y el algoritmo de cifrado utilizado para la firma, como el siguiente:

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

La información anterior es aproximadamente: el tipo de token es JWT, el algoritmo de cifrado utilizado para la firma es HS256. Finalmente, el objeto JSON se codifica en una cadena mediante Base64Url, esta cadena es el encabezado JWT.

Carga útil

La segunda parte de JWT es la parte de carga útil, que contiene principalmente declaraciones (claims). Las declaraciones suelen ser datos sobre una entidad, como un usuario. Hay tres tipos de declaraciones:

  • registered: Registered claims representa algunas declaraciones predefinidas, algunas no son obligatorias pero aún se recomiendan, como: iss (emisor), exp (tiempo de expiración), aud (audiencia).
  • public: Public claims puede ser definido libremente por las personas que usan JWT, es mejor evitar conflictos con otras declaraciones.
  • private claims: esta parte de las declaraciones también es personalizada, generalmente se usa para compartir información entre dos partes del servicio.

Un ejemplo de carga útil es el siguiente:

json
{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}

Este objeto JSON se codificará en una cadena mediante Base64Url, formando así la segunda parte de JWT.

DANGER

Aunque la parte de carga útil también está protegida y tiene prevención de alteración, esta parte es públicamente legible, así que no coloques información sensible dentro de JWT.

Firma

Después de obtener las partes de encabezado y carga útil codificadas, se puede cifrar y firmar mediante el algoritmo de firma indicado en el encabezado según el contenido de las dos partes anteriores más la clave secreta. Por lo tanto, una vez que el contenido de JWT cambia, la firma obtenida al descifrar será diferente. Si se usa una clave privada, también se puede verificar el firmante de JWT.

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

Por ejemplo:

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

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

Verify Signature
HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  your secret
)

Finalmente se obtiene una cadena compuesta por tres cadenas base64Url separadas por ., que se ve así:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
cThIIoDvwdueQB468K5xDc5633seEFoqwxjF_xSJyQQ

Principio de funcionamiento

En la autenticación, cuando un usuario inicia sesión correctamente con credenciales, se devolverá un token JSON Web. Dado que el token es una credencial, se debe tener mucho cuidado para prevenir problemas de seguridad. En general, el token no debe conservarse más tiempo del necesario. Luego, cada vez que el usuario quiera acceder a rutas y recursos protegidos, debe llevar el token al iniciar la solicitud, generalmente en el encabezado Authorization con el esquema Bearer, como el siguiente:

Authorization: Bearer <token>

Después de recibir el JWT, el servidor verificará su validez, como alteración de contenido, token expirado, etc. Si la verificación pasa, se podrá acceder a los recursos. Aunque JWT puede llevar alguna información básica, aún se recomienda que la información no sea demasiado grande.

Biblioteca JWT

Repositorio oficial: golang-jwt/jwt: Community maintained clone of https://github.com/dgrijalva/jwt-go

Documentación oficial: jwt package - github.com/golang-jwt/jwt/v4 - Go Packages

Esta biblioteca soporta análisis, verificación, generación y firma de JWT. Actualmente los algoritmos de firma soportados son HMAC SHA, RSA, RSA-PSS y ECDSA, aunque también se pueden agregar propios ganchos.

Instalación

go get -u github.com/golang-jwt/jwt/v4

Importar

go
import "github.com/golang-jwt/jwt/v4"

Seleccionar algoritmo de firma

Hay varios algoritmos de firma disponibles, antes de usarlos se debe entender la diferencia entre ellos para elegir mejor el algoritmo de firma. La mayor diferencia entre ellos es el cifrado simétrico y cifrado asimétrico.

El algoritmo de cifrado simétrico más simple HSA, permite que cualquier []byte se use como clave válida, por lo que la velocidad de cálculo es ligeramente más rápida. Cuando ambas partes productora y consumidora son confiables, la eficiencia del algoritmo de cifrado simétrico es la más alta. Sin embargo, dado que la firma y la verificación usan la misma clave, no es fácil distribuir la clave para verificación, después de todo, la clave de firma es la misma, si la clave de firma se filtra, la seguridad de JWT no tiene sentido.

Los métodos de firma de cifrado asimétrico, como RSA, usan diferentes claves para firmar y verificar el token, lo que hace posible generar tokens con clave privada y también permite que cualquiera con la clave pública verifique y acceda normalmente.

Diferentes algoritmos de firma requieren diferentes tipos de clave, a continuación se muestran algunos tipos de algoritmos de firma comunes:

  • HMAC: cifrado simétrico, requiere un valor de tipo []byte para firmar y verificar. (HS256,HS384,HS512)
  • RSA: cifrado asimétrico, requiere un valor de tipo *rsa.PrivateKey para firmar y un valor de tipo *rsa.PublicKey para verificar. (RS256,RS384,RS512)
  • ECDSA: cifrado asimétrico, requiere un valor de tipo *ecdsa.PrivateKey para firmar y un valor de tipo *ecdsa.PublicKey para verificar. (ES256,ES384,ES512)
  • EdDSA: cifrado asimétrico, requiere un valor de tipo ed25519.PrivateKey para firmar y un valor de tipo ed25519.PublicKey para verificar. (Ed25519)

Ejemplos

A continuación se mostrarán algunos ejemplos sobre la creación y firma de jwt, así como el análisis y verificación.

go
type Token struct {
  Raw       string                 // Cadena Token original, se rellena este campo cuando se comienza a analizar
  Method    SigningMethod          // Método usado para la firma
  Header    map[string]interface{} // Parte header de JWT
  Claims    Claims                 // Parte payload de JWT
  Signature string                 // Parte signature de JWT, se rellena este campo cuando se comienza a analizar
  Valid     bool                   // Si JWT es válido
}

La estructura Token representa un Token JWT, el uso de los campos depende principalmente de cómo se cree/firme o analice/verifique JWT.

go
type RegisteredClaims struct {
   // la declaración `iss` (Issuer). Ver https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.1
   Issuer string `json:"iss,omitempty"`

   // la declaración `sub` (Subject). Ver https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.2
   Subject string `json:"sub,omitempty"`

   // la declaración `aud` (Audience). Ver https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3
   Audience ClaimStrings `json:"aud,omitempty"`

   // la declaración `exp` (Expiration Time). Ver https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.4
   ExpiresAt *NumericDate `json:"exp,omitempty"`

   // la declaración `nbf` (Not Before). Ver https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.5
   NotBefore *NumericDate `json:"nbf,omitempty"`

   // la declaración `iat` (Issued At). Ver https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.6
   IssuedAt *NumericDate `json:"iat,omitempty"`

   // la declaración `jti` (JWT ID). Ver https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.7
   ID string `json:"jti,omitempty"`
}

Estas son las Claims predefinidas proporcionadas por la biblioteca, se pueden usar apropiadamente para alcanzar los objetivos.

Ejemplo 1. Creación y firma con HMAC

go
func TestHmac(t *testing.T) {
   // el tipo de clave secreta de hmac es un array de bytes
   secret := []byte("my secret")
   // usar algoritmo HS256, jwt.MapClaims es el payload
   token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
      "id":   123456,
      "name": "jack",
   })
   fmt.Printf("%+v\n", *token)
   // firmar
   signedString, err := token.SignedString(secret)
   fmt.Println(signedString, err)
}

Salida:

{Raw: Method:0xc000008150 Header:map[alg:HS256 typ:JWT] Claims:map[id:123456 name:jack] Signature: Valid:false}
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJpZCI6MTIzNDU2LCJuYW1lIjoiamFjayJ9.
QxLw9NkFgZW3BluyXIofe4efp1IAy61s8b2fe3Eo86M
<nil>

Ejemplo 2. Usar Claims predefinidas

go
mySigningKey := []byte("AllYourBase")

// crear Claims
claims := &jwt.RegisteredClaims{
  ExpiresAt: jwt.NewNumericDate(time.Unix(1516239022, 0)),
  Issuer:    "test",
}

token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
ss, err := token.SignedString(mySigningKey)
fmt.Printf("%v %v", ss, err)

Salida:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJpc3MiOiJ0ZXN0IiwiZXhwIjoxNTE2MjM5MDIyfQ.
0XN_1Tpp9FszFOonIBpwha0c_SfnNI22DhTnjMshPg8
<nil>

Ejemplo 3. Claims personalizadas

go
type MyClaims struct {
   User string `json:"user"`
   jwt.RegisteredClaims
}

func TestCustomClaims(t *testing.T) {
   // crear clave secreta
   secret := []byte("my secret")
   // crear Claims
   claims := MyClaims{
      User: "114514",
      RegisteredClaims: jwt.RegisteredClaims{
         ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
         IssuedAt:  jwt.NewNumericDate(time.Now()),
         NotBefore: jwt.NewNumericDate(time.Now()),
         Issuer:    "Server",
      },
   }
   // crear Token
   token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
   // firmar
   signedString, err := token.SignedString(secret)
   fmt.Println(signedString, err)
}

Salida:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJ1c2VyIjoiMTE0NTE0IiwiaXNzIjoiU2VydmVyIiwiZXhwIjoxNjczMDg1Nzk2LCJuYmYiOjE2NzMwODIxOTYsImlhdCI6MTY3MzA4MjE5Nn0.
PdPXdQBbDuYtE4ENXzoAcrW-dBSxqsufeYXCT5zTwVI
<nil>

TIP

Cuando se incrustan Claims estándar en Claims personalizadas, se debe asegurar:

  1. El tipo de Claims estándar incrustado no es un tipo puntero

  2. Si es un tipo puntero, es mejor asegurar que se le asigne memoria adecuada antes de pasarla, de lo contrario ocurrirá un panic.

Ejemplo 4. Análisis y verificación de Token HMAC

go
func TestParse(t *testing.T) {
   secret := []byte("my secret")
   // asumir que se creó y firmó un token usando el algoritmo HS256
   tokenString := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MTIzNDU2LCJuYW1lIjoiamFjayJ9.QxLw9NkFgZW3BluyXIofe4efp1IAy61s8b2fe3Eo86M"

   // pasar la cadena del token y la función de verificación, el valor de retorno es una estructura Token
   token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
      // verificar si el algoritmo de firma coincide
      if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
         return nil, fmt.Errorf("algoritmo de firma no coincidente: %s", token.Header["alg"])
      }

      // devolver la clave de verificación
      return secret, nil
   })
   if err != nil {
      fmt.Println(token, err)
   }

   if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
      fmt.Println(claims)
   } else {
      fmt.Println(err)
   }

}

Salida:

map[id:123456 name:jack]

Ejemplo 5. Manejo de errores

go
func TestProcess(t *testing.T) {
   secret := []byte("my secret")
   // asumir que se creó y firmó un token usando el algoritmo HS256
   tokenString := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MTIzNDU2LCJuYW1lIjoiamFjayJ9.QxLw9NkFgZW3BluyXIofe4efp1IAy61s8b2fe3Eo86M"

   // pasar la cadena del token y la función de verificación, el valor de retorno es una estructura Token
   token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
      // verificar si el algoritmo de firma coincide
      if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
         return nil, fmt.Errorf("algoritmo de firma no coincidente: %s", token.Header["alg"])
      }
      // devolver la clave de verificación
      return secret, nil
   })

   if token.Valid {
      fmt.Println("token válido")
   } else if errors.Is(err, jwt.ErrTokenMalformed) {
      fmt.Println("la cadena传入 ni siquiera es un token...")
   } else if errors.Is(err, jwt.ErrTokenExpired) || errors.Is(err, jwt.ErrTokenNotValidYet) {
      fmt.Println("el token ha expirado o aún no está vigente")
   } else {
      fmt.Println("el token tiene una excepción de procesamiento...")
   }
}

Salida:

token válido

Ejemplo 6. Análisis de Claims personalizadas

Si se usan Claims personalizadas al crear un Token, entonces al analizar, si se espera que Claims se pueda convertir directamente a Claims personalizadas en lugar de map, se necesita pasar Claims personalizadas.

go
func TestCustomClaimsParse(t *testing.T) {
  secret := []byte("my secret")
  tokenstring := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiMTE0NTE0IiwiaXNzIjoiU2VydmVyIiwiZXhwIjoxNjczMDg4MDU2LCJuYmYiOjE2NzMwODQ0NTYsImlhdCI6MTY3MzA4NDQ1Nn0.T245aoDeL2x19X8_JZde0EmZ2TDyIgr1u3ddKFjQmgw"
  token, err := jwt.ParseWithClaims(tokenstring, &MyClaims{}, func(token *jwt.Token) (interface{}, error) {
    return secret, nil
  }, jwt.WithValidMethods([]string{"HS256"})) // usar option para verificación

    // afirmación de tipo
  if claims, ok := token.Claims.(*MyClaims); ok && token.Valid {
    fmt.Println(claims)
  } else {
    fmt.Println(err)
  }
}

Salida:

&{114514 {Server  [] 2023-01-07 18:40:56 +0800 CST 2023-01-07 17:40:56 +0800 CST 2023-01-07 17:40:56 +0800 CST }}

Ejemplo 7. Firma y análisis con RSA

RSA se usa más en arquitecturas distribuidas, el proceso aproximado es el siguiente:

  1. El centro de autenticación crea un par de claves, usa la clave privada para firmar jwt, jwt se devuelve al cliente, la clave pública es mantenida por el servicio de negocio
  2. El cliente inicia una solicitud al servicio de negocio con jwt, el módulo de negocio analiza jwt usando la clave pública, sin necesidad de acceder al centro de autenticación
  3. Si la autenticación pasa, se devuelve la información de negocio
  4. Si la autenticación falla, se devuelve la información de fallo
go
func TestRsa(t *testing.T) {

   // crear par de claves
   privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
   publicKey := &privateKey.PublicKey

   if err != nil {
      fmt.Println(err)
      return
   }

   // claims
   claims := MyClaims{
      User: "114514",
      RegisteredClaims: jwt.RegisteredClaims{
         ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
         IssuedAt:  jwt.NewNumericDate(time.Now()),
         NotBefore: jwt.NewNumericDate(time.Now()),
         Issuer:    "Server",
      },
   }

   token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
   // cifrar con clave privada
   signedString, err := token.SignedString(privateKey)

   fmt.Println(signedString, err)

   // descifrar con clave pública
   token, err = jwt.ParseWithClaims(signedString, &MyClaims{}, func(token *jwt.Token) (interface{}, error) {
      return publicKey, nil
   })

   if err != nil {
      fmt.Println(err)
   } else if claims, ok := token.Claims.(*MyClaims); ok && token.Valid {
      fmt.Println(claims)
   }
}

Golang editado por www.golangdev.cn