[ID] Аутентификация по гибридной схеме: JWT + информация о пользователе

Как бэкенд-разработчик, вы, возможно, создали собственную систему аутентификации для своего приложения. Если вы управляете сеансами, например, используя базу данных, то это называется stateful.
Противоположностью stateful является stateless. Некоторые распространенные стандарты аутентификации без статических данных включают JWT и SAML.[1]

В этой статье мы рассмотрим, как используется JWT и почему мы не можем полностью полагаться на него.

Демо-приложение, которое мы будем создавать, основано на фреймворке Fibre.
Если у вас другие предпочтения, принцип работы JWT и этой гибридной схемы аутентификации должен быть одинаковым.

Инструменты разработки

  • Перейти
  • Visual Studio Code + расширение Go
  • Почтальон

Как работает JWT

JWT (произносится “джот”) – это интернет-стандарт для обмена информацией, изложенный в документе RFC 7519.
JSON Web Token, как следует из названия, несет полезную нагрузку в виде объекта JSON. Каждое свойство в нем называется требованием.

Начнем с создания папки, например, с именем how-to-hybrid-auth. Затем войдите в папку, откройте терминал и введите:

go mod init how-to-hybrid-auth
Войдите в полноэкранный режим Выход из полноэкранного режима

После этого появится файл go.mod.
Этот файл вместе с файлом go.sum будет использоваться для записи различных зависимостей проекта. Если проект не имеет нулевых зависимостей, требуя только стандартные пакеты, то будет только файл go.mod, без файла go.sum.

В этом учебнике структура проекта выглядит следующим образом:

.
├── go.mod
├── go.sum
├── main.go
└── pkg
    ├── authenticate
    │   └── handler.go
    ├── getaccess
    │   └── handler.go
    ├── getprofile
    │   └── handler.go
    ├── jwt
    │   └── jwt.go
    └── store
        ├── seeder.go
        └── store.go
Войдите в полноэкранный режим Выход из полноэкранного режима

1. Пример данных: пользователи

Например, существуют данные профиля пользователя как основа для идентификации пользователей приложений. Здесь мы храним данные в файле JSON.

Заполните файл pkg/store/store.go:

package store

import (
    "encoding/json"
    "os"
)

type (
    Item map[string]interface{}
    Data map[string]Item
)

func Get(storeName string) (Data, error) {
    // Read file content.
    content, err := os.ReadFile(storeName + ".json")
    if err != nil {
        return nil, err
    }

    // Parse it.
    var data Data
    if err := json.Unmarshal(content, &data); err != nil {
        return nil, err
    }

    return data, nil
}

func Set(storeName string, data Data, overwrite bool) error {
    // Serialize the data.
    out, err := json.MarshalIndent(data, "", "  ")
    if err != nil {
        return err
    }

    if overwrite {
        // Write file (overwrite existing file).
        return os.WriteFile(storeName+".json", out, os.ModePerm)
    }

    // Check if file doesn't exist.
    if _, err := os.Stat(storeName + ".json"); errors.Is(err, os.ErrNotExist) {
        // Write file.
        return os.WriteFile(storeName+".json", out, os.ModePerm)
    }

    return nil
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Функция Set форматирует данные в JSON и записывает их в файл, а функция Get считывает файл JSON и преобразует его в данные.

И содержимое файла pkg/store/seeder.go:

package store

func Seed() error {
    users := Data{
        "rI3sMqctRUv9Cv9CdvJIV": Item{
            "name": "Agni",
            "role": "Owner",
        },
        "ELjvSoCYudoMnlEYlhCjP": Item{
            "name": "Bisma",
            "role": "Maintainer",
        },
        "5hkcRZcrOWTxaeFhq3EdD": Item{
            "name": "Catur",
            "role": "Developer",
        },
    }
    if err := Set("users", users, false); err != nil {
        return err
    }

    return nil
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Функция Seed создаст исходные данные, если они еще не существуют.

Теперь используйте функцию в точке входа приложения, файле main.go:

package main

import (
    "how-to-hybrid-auth/pkg/store"
)

func main() {
    // Seed some data.
    if err := store.Seed(); err != nil {
        panic(err)
    }
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Если мы запустим приложение с помощью команды:

go run main.go
Войдите в полноэкранный режим Выход из полноэкранного режима

то появится файл users.json, который содержит исходные данные, как определено выше.

2. Создание маркера доступа

Здесь мы начинаем использовать Fibre, начиная с конечной точки API для создания маркера доступа.

Измените файл main.go следующим образом:

package main

import (
    "log"

    "github.com/gofiber/fiber/v2"

    "how-to-hybrid-auth/pkg/getaccess"
    "how-to-hybrid-auth/pkg/store"
)

func main() {
    // Seed some data.
    if err := store.Seed(); err != nil {
        panic(err)
    }

    // Create Fiber app.
    app := fiber.New()

    // External endpoints.
    app.Post("/get-access", getaccess.Handle)

    log.Fatal(app.Listen(":3000"))
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Чтобы включить конечную точку, сначала настройте специальную функцию для создания JWT-токена в файле pkg/jwt/jwt.go:

package jwtutil

import (
    "time"

    "github.com/golang-jwt/jwt/v4"
)

const (
    SigMethod = "HS256"
    SigKey    = "2022terceS"
    Issuer    = "localhost"
    Audience  = "localhost"
)

// Sign creates a token from the specified claims.
func Sign(claims jwt.MapClaims, expiresIn time.Duration) string {
    claims["iss"] = Issuer
    claims["aud"] = Audience

    now := time.Now()
    claims["iat"] = now.Unix()
    claims["exp"] = now.Add(expiresIn).Unix()

    token := jwt.NewWithClaims(jwt.GetSigningMethod(SigMethod), claims)
    out, _ := token.SignedString([]byte(SigKey))

    return out
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Здесь мы сначала используем простой метод подписания, а именно HS256, где ключ подписи используется симметричный: ключ, используемый для подписания и проверки JWT, абсолютно одинаков.[2]

Issuer = Сторона, выдавшая JWT. Аудитория = Сторона, которая использует JWT.
Если они одинаковые, это может означать, что JWT выдается и используется один. Хотя это не означает, что JWT не перейдет в другие руки.
По крайней мере, между серверным приложением и клиентским приложением находятся две разные стороны. JWT выдается сервером, затем “одалживается” клиенту, но только сервер имеет право проверить его.

После этого создайте функцию-обработчик, прикрепленную к конечной точке /get-access, в файле pkg/getaccess/handler.go:

package getaccess

import (
    "github.com/gofiber/fiber/v2"
    "github.com/golang-jwt/jwt/v4"

    jwtutil "how-to-hybrid-auth/pkg/jwt"
    "how-to-hybrid-auth/pkg/store"
)

type (
    findUser struct {
        ID string `form:"id"`
    }

    accessResponse struct {
        AccessToken string `json:"access_token"`
    }
)

func Handle(c *fiber.Ctx) error {
    // Find user by ID.
    fUser := new(findUser)
    if err := c.BodyParser(fUser); err != nil {
        return err
    }

    users, err := store.Get("users")
    if err != nil {
        return err
    }

    user, ok := users[fUser.ID]
    if !ok {
        return c.SendStatus(fiber.StatusNotFound)
    }

    // Generate access token.
    accessClaims := jwt.MapClaims{
        "sub": fUser.ID,
    }

    for k, v := range user {
        accessClaims[k] = v
    }

    accessToken := jwtutil.Sign(accessClaims, 5*time.Minute)

    return c.JSON(accessResponse{accessToken})
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Функция getaccess.Handle будет искать пользователей по ID. Если он есть, то возьмите всю информацию о пользователе в JWT утверждения, затем передайте ее в функцию jwtutil.Sign для объединения с некоторыми специальными утверждениями.
Для данного демонстрационного ролика мы сделали expiresIn (период действия JWT) равным 5 минутам, что примерно достаточно, чтобы увидеть изменение от valid до expired.
Наконец, выполняется подписание, в результате чего получается токен JWT.

Поскольку до этого момента мы использовали некоторые дополнительные зависимости (сторонние пакеты), включая gofiber/fiber/v2 и golang-jwt/jwt/v4, нам необходимо вызвать следующую команду:

go mod tidy
Войдите в полноэкранный режим Выход из полноэкранного режима

Таким образом, все зависимости становятся доступными для этого проекта.

Теперь запустите приложение снова:

go run main.go
Войдите в полноэкранный режим Выход из полноэкранного режима

Это показывает, что данное приложение работает с Fiber, HTTP-сервером на порту 3000.

Давайте попробуем вызвать конечную точку, созданную ранее.
Добавьте один запрос в Postman следующим образом:

  • Метод: ПОСТ
  • URL: http://localhost:3000/get-access
  • Тело: x-www-form-urlencoded
    • Id: { один из идентификаторов пользователя }

После нажатия кнопки Отправить вы получите следующий ответ:

{
    "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJsb2NhbGhvc3QiLCJleHAiOjE2NTI5NzA2MzYsImlhdCI6MTY1Mjk3MDMzNiwiaXNzIjoibG9jYWxob3N0IiwibmFtZSI6IkNhdHVyIiwicm9sZSI6IkRldmVsb3BlciIsInN1YiI6IjVoa2NSWmNyT1dUeGFlRmhxM0VkRCJ9.Qo70QaMH0NVo11i8u1uXgJssor1iAtGruXPiWG2PGF0"
}
Войдите в полноэкранный режим Выход из полноэкранного режима

3. Краткий обзор JWT-токена

Попробуйте скопировать ответ access_token, приведенный выше, или тот, который вы проверили сами, на сайт jwt.io.

Там мы можем посмотреть на содержимое токена, там есть три части: Заголовок, полезная нагрузка и подпись.

  • Я не объяснил раздел “Заголовок”.
  • Раздел “Полезная нагрузка” содержит все требования, которые были подготовлены ранее.
  • Раздел “Подпись” используется для проверки. Вы можете попробовать скопировать ключ подписи, определенный в исходном коде: “2022terceS”.

Теперь статус в левом нижнем углу становится “Подпись проверена”.

Токен, который можно разобрать перед проверкой таким образом, означает, что он относится к типу JWS (JSON Web Signature).
Другой тип – JWE (JSON Web Encryption), который зашифрован и требует секретного ключа для разблокировки.

Поэтому токен JWT должен быть одного из двух типов.
Обычно используется JWS, который содержит три вышеупомянутые части. JWE состоит из пяти частей, а одним из методов шифрования является AES256-GCM.[3][4]

4. JWT Middleware (разбор + проверка)

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

Заполните файл pkg/authenticate/handler.go:

package authenticate

import (
    "github.com/gofiber/fiber/v2"
    jwtware "github.com/gofiber/jwt/v3"

    jwtutil "how-to-hybrid-auth/pkg/jwt"
)

func New() fiber.Handler {
    return jwtware.New(jwtware.Config{
        SigningMethod: jwtutil.SigMethod,
        SigningKey:    []byte(jwtutil.SigKey),
        ContextKey:    "auth",
    })
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Чтобы проект распознал новую добавленную зависимость, вызовите следующую команду еще раз:

go mod tidy
Войдите в полноэкранный режим Выход из полноэкранного режима

Используемые метод подписи и ключ подписи указаны в файле pkg/jwt/jwt.go.
При минимальной конфигурации, как указано выше, проверка выполняется автоматически:

  • Подпись.
  • Срок действия.

Если вам нужно проверить другие вещи, например, Аудиторию, то она должна быть написана на заказ:

package authenticate

import (
    "fmt"

    "github.com/gofiber/fiber/v2"
    jwtware "github.com/gofiber/jwt/v3"
    "github.com/golang-jwt/jwt/v4"

    jwtutil "how-to-hybrid-auth/pkg/jwt"
)

func New() fiber.Handler {
    return jwtware.New(jwtware.Config{
        SigningMethod: jwtutil.SigMethod,
        SigningKey:    []byte(jwtutil.SigKey),
        ContextKey:    "auth",
        SuccessHandler: func(c *fiber.Ctx) error {
            auth := c.Locals("auth").(*jwt.Token)
            claims := auth.Claims.(jwt.MapClaims)

            // Verify audience.
            if !claims.VerifyAudience(jwtutil.Audience, true) {
                msg := fmt.Sprintf("Invalid JWT audience. Expected: %s", jwtutil.Audience)
                return c.Status(fiber.StatusUnauthorized).SendString(msg)
            }

            return c.Next()
        },
    })
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Это промежуточное ПО не может быть использовано, если еще нет конечной точки. Итак, перейдем к следующему пункту.

5. Запрос данных профиля пользователя

Создайте функцию-обработчик в файле pkg/getprofile/handler.go:

package getprofile

import (
    "github.com/gofiber/fiber/v2"
    "github.com/golang-jwt/jwt/v4"
)

var reservedClaims = map[string]struct{}{
    "iss": {},
    "aud": {},
    "exp": {},
    "nbf": {},
    "iat": {},
    "sub": {},
    "jti": {},
}

func Handle(c *fiber.Ctx) error {
    auth := c.Locals("auth").(*jwt.Token)
    claims := auth.Claims.(jwt.MapClaims)

    // Extract profile from JWT claims.
    profile := claims

    for k := range reservedClaims {
        delete(profile, k)
    }

    return c.JSON(profile)
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Профиль пользователя извлекается из утверждений JWT путем исключения семи специальных утверждений (если таковые имеются), называемых зарезервированными утверждениями.[5]

И измените файл main.go следующим образом:

package main

import (
    "log"

    "github.com/gofiber/fiber/v2"

    "how-to-hybrid-auth/pkg/authenticate"
    "how-to-hybrid-auth/pkg/getaccess"
    "how-to-hybrid-auth/pkg/getprofile"
    "how-to-hybrid-auth/pkg/store"
)

func main() {
    // Seed some data.
    if err := store.Seed(); err != nil {
        panic(err)
    }

    // Create Fiber app.
    app := fiber.New()

    // External endpoints.
    app.Post("/get-access", getaccess.Handle)

    // JWT middleware.
    app.Use(authenticate.New())

    // Internal endpoints.
    app.Post("/get-profile", getprofile.Handle)

    log.Fatal(app.Listen(":3000"))
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Поэтому обычно промежуточное ПО JWT располагается после внешних конечных точек и перед внутренними конечными точками.

Запустите приложение снова:

go run main.go
Войдите в полноэкранный режим Выход из полноэкранного режима

Затем перейдите в Postman и добавьте еще один запрос:

  • Метод: ПОСТ
  • URL: http://localhost:3000/get-profile
  • Тело: нет
  • Авторизация: Жетон на предъявителя

Введите маркер доступа в соответствующее поле.

Если токен недействителен или просрочен, вы получите ответ “Invalid or expired JWT”.

Между тем, если он действителен, вы получите следующий ответ:

{
    "name": "Catur",
    "role": "Developer"
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Ограничения JWT

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

Факты на местах, в различных системах предоставления идентификационных данных, находятся между двумя возможностями:

  • JWT-токен не является маркером доступа, а только ID-токеном. Именно так поступает компания Apple.[6]
  • Провайдер предоставляет конечную точку /userinfo и требует токен доступа для ее вызова. Это то, что делает Auth0.[7]

В случае с Apple, их не волнует изменение профиля пользователя, и они просто используют ID Token в качестве эталона, пока он действителен.
Со стороны приложения можно установить срок действия, например, в библиотеке albenik-go/apple-sign-in в функции Client.ValidateCode.

В случае Auth0 токен доступа, используемый для вызова конечной точки /userinfo, является непрозрачным. Это означает, что когда приложение получает маркер доступа, он не анализируется, поскольку не является JWT.

Userinfo: Вызовы и решения

Мы знаем, что аутентификация может управляться изнутри или извне.

Хотя выше мы упоминали об ограничениях JWT и преимуществах конечной точки /userinfo, иногда мы сталкиваемся с проблемами, когда приложение должно поддерживать несколько поставщиков идентификационных данных, таких как Google и Facebook.

  • У Google есть конечная точка /userinfo.
  • Facebook имеет конечную точку /me.

Если это всего лишь один источник данных, мы можем на него положиться. Но если их несколько, это тоже проблема.
Возможно, конечная точка полезна только для помощи в регистрации пользователя, для заполнения профиля в первую очередь. Это означает, что приложение должно хранить данные профиля локально.
В этом случае нет разницы, использует ли провайдер идентификации конечную точку /userinfo или ID Token, поскольку доступ к ней осуществляется только один раз для каждого пользователя.

Таким образом, мы можем сделать вывод, что в приложении всегда должны быть локальные данные профиля, независимо от того, является ли аутентификация внутренней или внешней.

Теперь о настройке, необходимой в нашем демонстрационном приложении:

В файле pkg/getaccess/handler.go, строки 35-49:

    _, ok := users[fUser.ID]
    if !ok {
        return c.SendStatus(fiber.StatusNotFound)
    }

    // Generate access token.
    accessClaims := jwt.MapClaims{
        "sub": fUser.ID,
    }

    accessToken := jwtutil.Sign(accessClaims, 5*time.Minute)
Войдите в полноэкранный режим Выход из полноэкранного режима

Там нам нужна только информация об идентификаторе пользователя, и мы не включаем никакую другую информацию для генерации Access Token.

Затем в файле pkg/getprofile/handler.go:

package getprofile

import (
    "github.com/gofiber/fiber/v2"
    "github.com/golang-jwt/jwt/v4"

    "how-to-hybrid-auth/pkg/store"
)

var reservedClaims = map[string]struct{}{
    "iss": {},
    "aud": {},
    "exp": {},
    "nbf": {},
    "iat": {},
    "sub": {},
    "jti": {},
}

func Handle(c *fiber.Ctx) error {
    auth := c.Locals("auth").(*jwt.Token)
    claims := auth.Claims.(jwt.MapClaims)

    // Extract user ID from JWT claims.
    userID, ok := claims["sub"].(string)
    if !ok {
        return c.SendStatus(fiber.StatusNotFound)
    }

    // Find user by ID.
    users, err := store.Get("users")
    if err != nil {
        return err
    }

    user, ok := users[userID]
    if !ok {
        return c.SendStatus(fiber.StatusNotFound)
    }

    return c.JSON(user)
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Там мы сначала убеждаемся, что JWT содержит утверждение “sub”, которое является идентификатором пользователя. После этого мы ищем данные пользователя на основе этого идентификатора.

Таким образом, полученные результаты всегда будут соответствовать текущим данным профиля.

Первый ответ:

{
    "name": "Catur",
    "role": "Developer"
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Второй ответ после изменения данных:

{
    "name": "Catur",
    "role": "Maintainer"
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Ссылки

  1. https://doubleoctopus.com/security-wiki/network-architecture/stateless-authentication/
  2. https://youtu.be/XfjQ2qO4ca8
  3. https://developer.okta.com/blog/2020/12/21/beginners-guide-to-jwt
  4. https://medium.facilelogin.com/jwt-jws-and-jwe-for-not-so-dummies-b63310d201a3
  5. https://auth0.com/docs/secure/tokens/json-web-tokens/json-web-token-claims
  6. https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/authenticating_users_with_sign_in_with_apple
  7. https://auth0.com/docs/secure/tokens/access-tokens

Дальнейшее чтение/просмотр

  1. Как выйти из системы при использовании JWT: https://medium.com/devgorilla/how-to-log-out-when-using-jwt-a8c7823e8a6.
  2. Познакомьтесь с OpenID Connect: https://www.youtube.com/watch?v=6DxRTJN1Ffo&list=PLKCk3OyNwIzuD_jxWu-JddooM2yjX5q99&index=12

Оцените статью
Procodings.ru
Добавить комментарий