Как бэкенд-разработчик, вы, возможно, создали собственную систему аутентификации для своего приложения. Если вы управляете сеансами, например, используя базу данных, то это называется 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"
}
Ссылки
- https://doubleoctopus.com/security-wiki/network-architecture/stateless-authentication/
- https://youtu.be/XfjQ2qO4ca8
- https://developer.okta.com/blog/2020/12/21/beginners-guide-to-jwt
- https://medium.facilelogin.com/jwt-jws-and-jwe-for-not-so-dummies-b63310d201a3
- https://auth0.com/docs/secure/tokens/json-web-tokens/json-web-token-claims
- https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/authenticating_users_with_sign_in_with_apple
- https://auth0.com/docs/secure/tokens/access-tokens
Дальнейшее чтение/просмотр
- Как выйти из системы при использовании JWT: https://medium.com/devgorilla/how-to-log-out-when-using-jwt-a8c7823e8a6.
- Познакомьтесь с OpenID Connect: https://www.youtube.com/watch?v=6DxRTJN1Ffo&list=PLKCk3OyNwIzuD_jxWu-JddooM2yjX5q99&index=12