Skip to content

JWT

JWT стал одним из основных способов аутентификации в современных серверных приложениях, обладая лёгкостью и отсутствием состояния.

Введение

JWT (JSON Web Tokens) — это открытый, безопасный, компактный способ передачи информации между сторонами в виде JSON-объектов. Его особенности — высокая безопасность, защита от изменений и низкое потребление ресурсов. Подробную информацию о JWT можно найти на jwt.io.

Структура

Согласно стандарту RFC, JWT состоит из следующих трёх частей:

  • Header (заголовок)
  • Payload (полезная нагрузка)
  • Signature (подпись)

Каждая часть разделяется точкой ., образуя строку в формате header.payload.signature. Это стандартная структура токена JWT. Далее рассмотрим назначение каждой части.

TIP

Следует отметить, что base64 и base64URL — это разные способы кодирования. Последний совместим с URL веб-страниц и выполняет экранирование.

Заголовок

Заголовок объявляет базовую информацию, обычно состоящую из двух частей: типа токена и алгоритма шифрования, используемого для подписи. Например:

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

Эта информация означает, что тип токена — JWT, а алгоритм шифрования для подписи — HS256. Затем JSON-объект кодируется в строку Base64Url, которая и является заголовком JWT.

Полезная нагрузка

Вторая часть JWT — полезная нагрузка, содержащая утверждения (claims). Утверждения обычно содержат данные о сущности, например, о пользователе. Существует три типа утверждений:

  • registered: Зарегистрированные утверждения — это предопределённые утверждения, которые не являются обязательными, но рекомендуются к использованию. Например: iss (эмитент), exp (время истечения), aud (аудитория).
  • public: Публичные утверждения могут быть произвольно определены пользователями JWT. Следует избегать конфликтов с другими утверждениями.
  • private claims: Эти утверждения также являются пользовательскими и обычно используются для обмена информацией между сторонами.

Пример полезной нагрузки:

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

Этот JSON-объект будет закодирован в строку Base64Url, образуя вторую часть JWT.

DANGER

Хотя полезная нагрузка защищена от изменений, она является общедоступной для чтения. Поэтому не следует размещать конфиденциальную информацию в JWT.

Подпись

После получения закодированных заголовка и полезной нагрузки можно создать подпись с помощью алгоритма подписи, указанного в заголовке, используя содержимое первых двух частей и секретный ключ. Таким образом, любое изменение содержимого JWT приведёт к изменению подписи при расшифровке. При использовании закрытого ключа также можно проверить эмитента JWT.

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

Пример:

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

Payload
{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022
}

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

В результате получается строка, состоящая из трёх строк base64Url, разделённых точками, примерно так:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
cThIIoDvwdueQB468K5xDc5633seEFoqwxjF_xSJyQQ

Принцип работы

В аутентификации, когда пользователь успешно входит в систему с учётными данными, возвращается JSON Web токен. Поскольку токен является учётными данными, следует очень внимательно относиться к предотвращению проблем безопасности. Как правило, токен не должен храниться дольше необходимого. Затем, когда пользователь хочет получить доступ к защищённым маршрутам и ресурсам, при отправке запроса необходимо включать токен, обычно в заголовке Authorization с схемой Bearer:

Authorization: Bearer <token>

Сервер после получения JWT проверяет его действительность, например, на наличие изменений в содержимом или истечение срока действия токена. Если проверка проходит успешно, доступ к ресурсам разрешается. Хотя JWT может содержать некоторую базовую информацию, всё же рекомендуется не делать информацию слишком большой.

Библиотека JWT

Официальный репозиторий: golang-jwt/jwt: Community maintained clone of https://github.com/dgrijalva/jwt-go

Официальная документация: jwt package - github.com/golang-jwt/jwt/v4 - Go Packages

Эта библиотека поддерживает парсинг, проверку, генерацию и подпись JWT. В настоящее время поддерживаемые алгоритмы подписи: HMAC SHA, RSA, RSA-PSS и ECDSA. Также можно добавлять собственные хуки.

Установка

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

Импорт

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

Выбор алгоритма подписи

Существует несколько алгоритмов подписи. Перед использованием следует ознакомиться с их различиями для лучшего выбора алгоритма подписи. Основное различие между ними — симметричное шифрование и асимметричное шифрование.

Простейший алгоритм симметричного шифрования HSA позволяет использовать любой []byte в качестве действительного ключа, поэтому скорость вычислений немного выше. Алгоритмы симметричного шифрования наиболее эффективны, когда обе стороны (производитель и потребитель) являются доверенными. Однако, поскольку для подписи и проверки используется один и тот же ключ, невозможно легко распространить ключ для проверки токена, ведь ключ для подписи тот же самый. При утечке ключа подписи безопасность JWT теряет смысл.

Асимметричные методы подписи, такие как RSA, используют разные ключи для подписи и проверки токена. Это делает возможным создание токенов с закрытым ключом, одновременно позволяя любому, у кого есть открытый ключ, выполнять проверку.

Различные алгоритмы подписи требуют разных типов ключей. Ниже приведены типы некоторых распространённых алгоритмов подписи:

  • HMAC: Симметричное шифрование, требует значение типа []byte для подписи и проверки. (HS256, HS384, HS512)
  • RSA: Асимметричное шифрование, требует значение типа *rsa.PrivateKey для подписи и значение типа *rsa.PublicKey для проверки. (RS256, RS384, RS512)
  • ECDSA: Асимметричное шифрование, требует значение типа *ecdsa.PrivateKey для подписи и значение типа *ecdsa.PublicKey для проверки. (ES256, ES384, ES512)
  • EdDSA: Асимметричное шифрование, требует значение типа ed25519.PrivateKey для подписи и значение типа ed25519.PublicKey для проверки. (Ed25519)

Примеры

Ниже приведены некоторые примеры создания и подписи JWT, а также парсинга и проверки.

go
type Token struct {
  Raw       string                 // Исходная строка токена, заполняется при начале парсинга
  Method    SigningMethod          // Метод, использованный для подписи
  Header    map[string]interface{} // Часть заголовка JWT
  Claims    Claims                 // Часть полезной нагрузки JWT
  Signature string                 // Часть подписи JWT, заполняется при начале парсинга
  Valid     bool                   // Действителен ли JWT
}

Структура Token представляет токен JWT. Использование полей в основном зависит от того, как JWT создаётся/подписывается или парсится/проверяется.

go
type RegisteredClaims struct {
   // Утверждение `iss` (Issuer). См. https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.1
   Issuer string `json:"iss,omitempty"`

   // Утверждение `sub` (Subject). См. https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.2
   Subject string `json:"sub,omitempty"`

   // Утверждение `aud` (Audience). См. https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3
   Audience ClaimStrings `json:"aud,omitempty"`

   // Утверждение `exp` (Expiration Time). См. https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.4
   ExpiresAt *NumericDate `json:"exp,omitempty"`

   // Утверждение `nbf` (Not Before). См. https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.5
   NotBefore *NumericDate `json:"nbf,omitempty"`

   // Утверждение `iat` (Issued At). См. https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.6
   IssuedAt *NumericDate `json:"iat,omitempty"`

   // Утверждение `jti` (JWT ID). См. https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.7
   ID string `json:"jti,omitempty"`
}

Это предопределённые утверждения Claims, предоставляемые библиотекой. Их можно использовать соответствующим образом для достижения необходимых целей.

Пример 1. Создание и подпись HMAC

go
func TestHmac(t *testing.T) {
   // Тип ключа для hmac — массив байтов
   secret := []byte("my secret")
   // Использование алгоритма HS256, jwt.MapClaims — это полезная нагрузка
   token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
      "id":   123456,
      "name": "jack",
   })
   fmt.Printf("%+v\n", *token)
   // Подпись
   signedString, err := token.SignedString(secret)
   fmt.Println(signedString, err)
}

Вывод:

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

Пример 2. Использование предопределённых Claims

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

// Создание 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)

Вывод:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJpc3MiOiJ0ZXN0IiwiZXhwIjoxNTE2MjM5MDIyfQ.
0XN_1Tpp9FszFOonIBpwha0c_SfnNI22DhTnjMshPg8
<nil>

Пример 3. Пользовательские Claims

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

func TestCustomClaims(t *testing.T) {
   // Создание ключа
   secret := []byte("my secret")
   // Создание 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.SigningMethodHS256, claims)
   // Подпись
   signedString, err := token.SignedString(secret)
   fmt.Println(signedString, err)
}

Вывод:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJ1c2VyIjoiMTE0NTE0IiwiaXNzIjoiU2VydmVyIiwiZXhwIjoxNjczMDg1Nzk2LCJuYmYiOjE2NzMwODIxOTYsImlhdCI6MTY3MzA4MjE5Nn0.
PdPXdQBbDuYtE4ENXzoAcrW-dBSxqsufeYXCT5zTwVI
<nil>

TIP

При встраивании стандартных Claims в пользовательские Claims следует убедиться:

  1. Встраиваемые стандартные Claims являются типом не указатель.

  2. Если это тип указатель, лучше убедиться, что перед передачей выделена соответствующая память, иначе произойдёт паника.

Пример 4. Парсинг и проверка токена HMAC

go
func TestParse(t *testing.T) {
   secret := []byte("my secret")
   // Предположим, что токен был создан и подписан с помощью алгоритма HS256
   tokenString := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MTIzNDU2LCJuYW1lIjoiamFjayJ9.QxLw9NkFgZW3BluyXIofe4efp1IAy61s8b2fe3Eo86M"

   // Передача строки токена и функции проверки хука, возвращаемое значение — структура Token
   token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
      // Проверка соответствия алгоритма подписи
      if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
         return nil, fmt.Errorf("Несоответствующий алгоритм подписи: %s", token.Header["alg"])
      }

      // Возврат ключа проверки
      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)
   }

}

Вывод:

map[id:123456 name:jack]

Пример 5. Обработка ошибок

go
func TestProcess(t *testing.T) {
   secret := []byte("my secret")
   // Предположим, что токен был создан и подписан с помощью алгоритма HS256
   tokenString := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MTIzNDU2LCJuYW1lIjoiamFjayJ9.QxLw9NkFgZW3BluyXIofe4efp1IAy61s8b2fe3Eo86M"

   // Передача строки токена и функции проверки хука, возвращаемое значение — структура Token
   token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
      // Проверка соответствия алгоритма подписи
      if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
         return nil, fmt.Errorf("Несоответствующий алгоритм подписи: %s", token.Header["alg"])
      }
      // Возврат ключа проверки
      return secret, nil
   })

   if token.Valid {
      fmt.Println("токен действителен")
   } else if errors.Is(err, jwt.ErrTokenMalformed) {
      fmt.Println("переданная строка даже не является токеном...")
   } else if errors.Is(err, jwt.ErrTokenExpired) || errors.Is(err, jwt.ErrTokenNotValidYet) {
      fmt.Println("токен истёк или ещё не действителен")
   } else {
      fmt.Println("обработка токена не удалась...")
   }
}

Вывод:

токен действителен

Пример 6. Парсинг пользовательских Claims

Если при создании токена использовались пользовательские Claims, то при парсинге, если требуется, чтобы Claims преобразовывались в пользовательские Claims, а не в map, нужно передать пользовательские Claims.

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"})) // Использование опции для проверки

    // Утверждение типа
  if claims, ok := token.Claims.(*MyClaims); ok && token.Valid {
    fmt.Println(claims)
  } else {
    fmt.Println(err)
  }
}

Вывод:

&{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 }}

Пример 7. Подпись и парсинг RSA

RSA чаще используется в распределённых архитектурах. Примерный процесс следующий:

  1. Центр аутентификации создаёт пару ключей, подписывает JWT с помощью закрытого ключа, JWT возвращается клиенту, открытый ключ хранится сервисом.
  2. Клиент отправляет запрос к сервису с JWT, сервис использует открытый ключ для парсинга JWT без необходимости обращения к центру аутентификации.
  3. При успешной аутентификации возвращается бизнес-информация.
  4. При неудачной аутентификации возвращается сообщение об ошибке.
go
func TestRsa(t *testing.T) {

   // Создание пары ключей
   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)
   // Шифрование закрытым ключом
   signedString, err := token.SignedString(privateKey)

   fmt.Println(signedString, err)

   // Расшифровка открытым ключом
   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)
   }
}

Интеграция с веб-фреймворками

Интеграция с Gin

go
package main

import (
    "github.com/gin-gonic/gin"
    "github.com/golang-jwt/jwt/v4"
    "time"
)

// Middleware JWT
func JWTMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        // Получение токена из заголовка
        authHeader := c.GetHeader("Authorization")
        if authHeader == "" {
            c.JSON(401, gin.H{"error": "Токен не предоставлен"})
            c.Abort()
            return
        }

        // Парсинг токена
        token, err := jwt.Parse(authHeader, func(token *jwt.Token) (interface{}, error) {
            return []byte("my secret"), nil
        })

        if err != nil || !token.Valid {
            c.JSON(401, gin.H{"error": "Недействительный токен"})
            c.Abort()
            return
        }

        // Сохранение claims в контексте
        if claims, ok := token.Claims.(jwt.MapClaims); ok {
            c.Set("user_id", claims["id"])
            c.Set("user_name", claims["name"])
        }

        c.Next()
    }
}

func main() {
    r := gin.Default()
    
    // Использование middleware
    r.Use(JWTMiddleware())
    
    r.GET("/profile", func(c *gin.Context) {
        userID := c.GetInt("user_id")
        userName := c.GetString("user_name")
        c.JSON(200, gin.H{
            "user_id": userID,
            "user_name": userName,
        })
    })
    
    // Маршрут входа
    r.POST("/login", func(c *gin.Context) {
        // Проверка учётных данных пользователя
        username := c.PostForm("username")
        password := c.PostForm("password")
        
        if username == "admin" && password == "password" {
            // Создание токена
            token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
                "id":   1,
                "name": username,
                "exp":  time.Now().Add(time.Hour * 24).Unix(),
            })
            
            tokenString, err := token.SignedString([]byte("my secret"))
            if err != nil {
                c.JSON(500, gin.H{"error": "Ошибка создания токена"})
                return
            }
            
            c.JSON(200, gin.H{"token": tokenString})
        } else {
            c.JSON(401, gin.H{"error": "Неверные учётные данные"})
        }
    })
    
    r.Run()
}

Заключение

JWT — это мощный и гибкий стандарт для аутентификации и обмена информацией, который широко используется в современных веб-приложениях и микросервисных архитектурах. Благодаря своей простоте, безопасности и независимости от состояния, JWT стал популярным выбором для реализации аутентификации в Go-приложениях.

Golang by www.golangdev.cn edit