JWT
JWT стал одним из основных способов аутентификации в современных серверных приложениях, обладая лёгкостью и отсутствием состояния.
Введение
JWT (JSON Web Tokens) — это открытый, безопасный, компактный способ передачи информации между сторонами в виде JSON-объектов. Его особенности — высокая безопасность, защита от изменений и низкое потребление ресурсов. Подробную информацию о JWT можно найти на jwt.io.
Структура
Согласно стандарту RFC, JWT состоит из следующих трёх частей:
- Header (заголовок)
- Payload (полезная нагрузка)
- Signature (подпись)
Каждая часть разделяется точкой ., образуя строку в формате header.payload.signature. Это стандартная структура токена JWT. Далее рассмотрим назначение каждой части.
TIP
Следует отметить, что base64 и base64URL — это разные способы кодирования. Последний совместим с URL веб-страниц и выполняет экранирование.
Заголовок
Заголовок объявляет базовую информацию, обычно состоящую из двух частей: типа токена и алгоритма шифрования, используемого для подписи. Например:
{
"alg": "HS256",
"typ": "JWT"
}Эта информация означает, что тип токена — JWT, а алгоритм шифрования для подписи — HS256. Затем JSON-объект кодируется в строку Base64Url, которая и является заголовком JWT.
Полезная нагрузка
Вторая часть JWT — полезная нагрузка, содержащая утверждения (claims). Утверждения обычно содержат данные о сущности, например, о пользователе. Существует три типа утверждений:
registered: Зарегистрированные утверждения — это предопределённые утверждения, которые не являются обязательными, но рекомендуются к использованию. Например:iss(эмитент),exp(время истечения),aud(аудитория).public: Публичные утверждения могут быть произвольно определены пользователями JWT. Следует избегать конфликтов с другими утверждениями.private claims: Эти утверждения также являются пользовательскими и обычно используются для обмена информацией между сторонами.
Пример полезной нагрузки:
{
"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Импорт
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, а также парсинга и проверки.
type Token struct {
Raw string // Исходная строка токена, заполняется при начале парсинга
Method SigningMethod // Метод, использованный для подписи
Header map[string]interface{} // Часть заголовка JWT
Claims Claims // Часть полезной нагрузки JWT
Signature string // Часть подписи JWT, заполняется при начале парсинга
Valid bool // Действителен ли JWT
}Структура Token представляет токен JWT. Использование полей в основном зависит от того, как JWT создаётся/подписывается или парсится/проверяется.
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
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
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
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 следует убедиться:
Встраиваемые стандартные Claims являются типом не указатель.
Если это тип указатель, лучше убедиться, что перед передачей выделена соответствующая память, иначе произойдёт паника.
Пример 4. Парсинг и проверка токена HMAC
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. Обработка ошибок
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.
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 чаще используется в распределённых архитектурах. Примерный процесс следующий:
- Центр аутентификации создаёт пару ключей, подписывает JWT с помощью закрытого ключа, JWT возвращается клиенту, открытый ключ хранится сервисом.
- Клиент отправляет запрос к сервису с JWT, сервис использует открытый ключ для парсинга JWT без необходимости обращения к центру аутентификации.
- При успешной аутентификации возвращается бизнес-информация.
- При неудачной аутентификации возвращается сообщение об ошибке.
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
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-приложениях.
