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

Выбор библиотек для работы с PASETO

В настоящее время PASETO имеет 4 версии, 1 и 2 из них считаются устаревшими, поэтому при выборе библиотек важно ориентироваться на те, которые будут работать с версиями 3 или 4.

Например, Гугл и гитхаб при поиске библиотеки для Go в первых строках поиска выдают этот репозиторий. Но он работает только с версиями PASETO 1 и 2, поэтому сейчас его уже не стоит использовать.

На момент создания статьи (апрель 2024 года) есть два репозитория с актуальными версиями токена. Вот ссылки на первый и второй

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

1. github.com/gofiber/fiber — роутинг и обработка запросов.

2. github.com/spf13/viper — чтение конфига.

3. github.com/stretchr/testify — для тестов.

4. github.com/vk-rv/pvx   для обработки PASETO токенов.

Работа с конфигом

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

TOKEN_KEY=000f3e5799296cc4ce32c444cfde4962
TOKEN_DURATION=60m
Address=localhost:4444

Код для чтения конфига представлен ниже:

package appconfig


import (
   "github.com/spf13/viper"
   "time"
)


type Config struct {
   TokenKey      string        `mapstructure:"TOKEN_KEY"`
   TokenDuration time.Duration `mapstructure:"TOKEN_DURATION"`
   Address       string        `mapstructure:"ADDRESS"`
}


func Load(path string) (config *Config, err error) {


   viper.SetConfigFile(path)


   err = viper.ReadInConfig()
   if err != nil {
       return
   }


   err = viper.Unmarshal(&config)
   return
}

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

Модуль работы с токеном PASETO

Модуль будет лежать по пути internal/auth/paseto.go. Стоит сразу отметить, что у PASETO есть два режима работы: local и public. В первом случае токен шифруется симметричным ключом и его содержимое будет нельзя прочесть без ключа. Во втором случае токен подписывается ассиметричным ключом, его содержимое можно будет прочесть, но нельзя будет изменить. В статье рассмотрим работу с local токенами 4 версии.

Для начала мы создадим структуру PasetoAuth, в которой будет храниться ключ в виде слайса байт и ключ из пакета PASETO. По сути, это структура, которая хранит в себе версию токена и ключ. Важно, чтобы длина ключа была ровно 32 байта, об этом подробнее можно прочитать тут.

Далее мы задаем длину ключа и ошибку, которая будет возвращаться в случае, если пользователь введёт неправильную длину ключа. Функция NewPaseto создаёт объект, при помощи которого мы будем работать с токенами в приложении, в ней мы просто инициализируем начальные значения для структур.

package auth


import (
   "fmt"
   "github.com/vk-rv/pvx"
   "pasetoservice/internal/models"
   "time"
)


type PasetoAuth struct {
   pasetoKey    *pvx.SymKey
   symmetricKey []byte
}


const keySize = 32


var ErrInvalidSize = fmt.Errorf("bad key size: it must be %d bytes", keySize)


func NewPaseto(key []byte) (*PasetoAuth, error) {


   if len(key) != keySize {
       return nil, ErrInvalidSize
   }


   pasetoKey := pvx.NewSymmetricKey(key, pvx.Version4)


   return &PasetoAuth{
       symmetricKey: key,
       pasetoKey:    pasetoKey,
   }, nil
}

Далее рассмотрим код для генерации нового токена. Создаем новый пакет internal/models, в котором будет лежать большинство структур сервиса. Создадим структуру ServiceClaims, которая будет использоваться для маршалинга/анмаршалинга данных в токены. Поле pvx.RegisteredClaims является обязательным, т. к. именно в нём будут храниться обязательные поля. Далее идет поле AdditionalClaims, в него можно добавить любые поля, которые не предусмотрены в PASETO. Затем идёт Footer, в который записывают опциональную информацию. Особенность футера в том, что эта часть токена не шифруется в отличие от pvx.RegisteredClaims и AdditionalClaims.

//internal/models/models.go
type AdditionalClaims struct {
   Name string `json:"name"`
   Role string `json:"role"`
}


type Footer struct {
   MetaData string `json:"meta_data"`
}

type ServiceClaims struct {
   pvx.RegisteredClaims
   AdditionalClaims
   Footer
}

Далее нужно создать структуру, которая будет использоваться для создания токенов. В данном случае может несколько вариантов, т. к. в некоторых случаях достаточно будет установить только поля IssuedAt и Expiration, а в других — нужно будет установить все поля. В рамках статьи я предлагаю использовать следующую структуру:

type TokenData struct {
   Subject  string
   Duration time.Duration
   AdditionalClaims
   Footer
}

Теперь перейдём к функции создания нового токена. Для начала создаём объект serviceClaims, далее устанавливаем поля IssuedAt и Expiration, после устанавливаем уже опциональные поля. Затем мы создаём новый объект при помощи функции pvx.NewPV4Local(), из названия можно понять, что она возвращает объект для работы с local токенами 4 версии. Для создания нового токена вызываем метод Encrypt у объекта. Обратите внимание на то, что для создания токена необходимы только 2 аргумента: секретный ключ и serviceClaims, футер является опциональным параметром. После процесса шифрования функция Encrypt вернет токен в виде строки.

func (pa *PasetoAuth) NewToken(data models.TokenData) (string, error) {


   serviceClaims := &models.ServiceClaims{}


   iss := time.Now()
   exp := iss.Add(data.Duration)


   serviceClaims.IssuedAt = &iss
   serviceClaims.Expiration = &exp
   serviceClaims.Subject = data.Subject


   serviceClaims.AdditionalClaims = data.AdditionalClaims
   serviceClaims.Footer = data.Footer


   pv4 := pvx.NewPV4Local()


   authToken, err := pv4.Encrypt(pa.pasetoKey, serviceClaims,
       pvx.WithFooter(serviceClaims.Footer))
   if err != nil {
       return "", err
   }


   return authToken, nil


}

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

func (pa *PasetoAuth) VerifyToken(token string) (*models.ServiceClaims, error) {
   pv4 := pvx.NewPV4Local()
   tk := pv4.Decrypt(token, pa.pasetoKey)


   f := models.Footer{}
   sc := models.ServiceClaims{
       Footer: f,
   }


   err := tk.Scan(&sc, &f)
   if err != nil {
       return &sc, err
   }


   return &sc, nil
}

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

package auth


import (
   "github.com/stretchr/testify/require"
   "pasetoservice/internal/models"
   "testing"
   "time"
)


func TestServiceGenerateNewKey(t *testing.T) {


   key := []byte("000f3e5799296cc4ce32c444cfde4962")


   pasetoToken, err := NewPaseto(key)
   require.NoError(t, err)


   token, err := pasetoToken.NewToken(models.TokenData{
       Subject:  "test",
       Duration: 5 * time.Second,
       AdditionalClaims: models.AdditionalClaims{
           Name: "add name",
           Role: "test-role",
       },
       Footer: models.Footer{MetaData: "footer"},
   })


   require.NoError(t, err)
   require.NotEmpty(t, token)


   sc, err := pasetoToken.VerifyToken(token)
   require.NoError(t, err)
   require.Equal(t, "footer", sc.Footer.MetaData)
   require.Equal(t, "add name", sc.AdditionalClaims.Name)
   require.Equal(t, "test-role", sc.AdditionalClaims.Role)


}

Далее напишем следующий тест, в котором проверим, что функция NewPaseto возвращает ошибку при неправильном размере ключа. После передадим в функцию NewToken, объект, в котором Duration будет иметь отрицательное значение. Так мы проверим, как будет себя вести токен в случае, если время его работы истечет. Как мы видим, при верификации токена возвращается ошибка, как и ожидалось:

func TestInvalidCases(t *testing.T) {


   badKey := []byte("00")
   pasetoToken, err := NewPaseto(badKey)
   require.ErrorIs(t, err, ErrInvalidSize)


   key := []byte("000f3e5799296cc4ce32c444cfde4962")
   pasetoToken, err = NewPaseto(key)
   require.NoError(t, err)


   token, err := pasetoToken.NewToken(models.TokenData{
       Duration: -5 * time.Second,
   })
   require.NoError(t, err)


   sc, err := pasetoToken.VerifyToken(token)
   require.Error(t, err)
   require.Error(t, sc.Valid())


}

Теперь перейдём непосредственно к написанию API, для начала создадим директорию internal/api и следующую структуру в файле server.go:

type App struct {
   token     *auth.PasetoAuth
   routerApi *fiber.App
   config    *appconfig.Config
}

Данная структура будет основой для создания хендлеров. Теперь перейдём к созданию аутентификационного middleware. Создадим его в отдельном файле middleware.go:

package api


import (
   "errors"
   "fmt"
   "github.com/gofiber/fiber/v2"
   "strings"
)


const (
   authHeader = "Authorization"
   typeBearer = "bearer"
)


var (
   ErrMissingAuthHeader = errors.New("authorization header is missing")
   ErrInvalidAuthHeader = errors.New("invalid authorization header")
)


func (a *App) CheckAuth() fiber.Handler {
   return func(c *fiber.Ctx) error {
       authVal := c.Get(authHeader)


       if len(authVal) == 0 {
           return c.Status(fiber.StatusUnauthorized).
               JSON(fiber.Map{"error": ErrMissingAuthHeader.Error()})
       }


       splitHeader := strings.Fields(authVal)


       if len(splitHeader) < 2 {
           return c.Status(fiber.StatusUnauthorized).
               JSON(fiber.Map{"error": ErrInvalidAuthHeader.Error()})
       }


       authType := strings.ToLower(splitHeader[0])
       if authType != typeBearer {
           err := fmt.Errorf("unsupported authorization type %s", authType)
           return c.Status(fiber.StatusUnauthorized).
               JSON(fiber.Map{"error": err.Error()})
       }


       claims, err := a.token.VerifyToken(splitHeader[1])
       if err != nil {
           return c.Status(fiber.StatusUnauthorized).
               JSON(fiber.Map{"error": err.Error()})
       }


       c.Locals("claims", claims)


       return c.Next()
   }
}

В статье мы рассмотрим вариант аутентификации при помощи заголовка Authorization. В middleware мы получаем значение из данного заголовка, после чего пытаемся верифицировать токен и схему. Если нам это не удается, отправляем статус 401 (Unauthorized) и прекращаем обработку запроса. Если токен прошел проверку, то помещаем в локальное хранилище/контекст запроса объект ServiceClaims, полученный после расшифровки токена.

Далее в файле users.go создадим локальное хранилище с пользователями, которые будут использоваться для демонстрации работы API:

package api

var users = map[string]string{
   "user":  "user",
   "admin": "admin",
}

Первым хендлером будет Login, его мы будем использовать для выдачи новых токенов. Данный хендлер разместим в файле paseto_handlers.go.

Перед тем как начать писать код хендлера, в файл models/models.go добавим структуру, в которую будем помещать аутентификационные данные пользователя. Предполагается, что пользователь будет присылать свои данные из форм:

type Credentials struct {
   Password string `form:"password"`
   Username string `form:"username"`
}

Теперь рассмотрим, как работает хэндлер Login. В начале мы пытаемся получить данные из тела запроса, если на этом этапе возникает ошибка, то возвращаем код 400 (Bad Request). Если данные из форм извлекались нормально, то проверяем, что пользователь существует и что его пароль верный. Если аутентификационные данные неправильные, то возвращаем 401 (Unauthorized). 

После проверки пользовательских данных мы создаем новый токен. Duration мы берём из конфига, который мы загрузили в начале. Остальные данные заполняются в произвольной форме для демонстрации. В зависимости от результата формирования токена мы возвращаем либо ошибку со статусом 500 (Internal Server Error), либо статус 200 (OK) и токен в виде строки:

package api


import (
   "github.com/gofiber/fiber/v2"
   "pasetoservice/internal/models"
)


func (a *App) Login(c *fiber.Ctx) error {
   var creds models.Credentials


   if err := c.BodyParser(&creds); err != nil {
       return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
   }


   expectedPass, ok := users[creds.Username]
   if !ok || expectedPass != creds.Password {
       return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "bad password or username"})
   }


   pasetoToken, err := a.token.NewToken(models.TokenData{
       Subject:  "for user",
       Duration: a.config.TokenDuration,
       AdditionalClaims: models.AdditionalClaims{
           Name: creds.Username,
           Role: creds.Username,
       },
       Footer: models.Footer{MetaData: "footer for " + creds.Username},
   })


   if err != nil {
       return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
   }


   return c.Status(fiber.StatusOK).JSON(fiber.Map{"token": pasetoToken})
}

Теперь перейдем к написанию остального API и функции для создания нового приложения. В функцию NewApp мы передаем конфиг и объект с типом *fiber.App. Данный объект используется для создания новых хендлеров, а также определяет, какие запросы будут ими приниматься, а также url, по которым к ним можно будет обратиться:

package api


import (
   "fmt"
   "github.com/gofiber/fiber/v2"
   "pasetoservice/internal/appconfig"
   "pasetoservice/internal/auth"
   "pasetoservice/internal/models"
)


type App struct {
   token     *auth.PasetoAuth
   routerApi *fiber.App
   config    *appconfig.Config
}


func NewApp(c *appconfig.Config, routerApi *fiber.App) (*App, error) {


   pasetoToken, err := auth.NewPaseto([]byte(c.TokenKey))
   if err != nil {
       return nil, err
   }


   app := &App{
       token:     pasetoToken,
       routerApi: routerApi,
       config:    c,
   }


   app.SetApi()


   return app, nil
}

Далее реализуем метод SetApi у структуры App, в нем мы зададим основные хендлеры для приложения:

func (a *App) SetApi() {
   a.routerApi.Post("/login", a.Login)


   protectedApi := a.routerApi.Group("/api", a.CheckAuth())


   protectedApi.Get("/account", func(c *fiber.Ctx) error {


       val := c.Locals("claims")


       v, ok := val.(*models.ServiceClaims)


       if !ok {
           return c.Status(fiber.StatusInternalServerError).
JSON(fiber.Map{"error": "type conversion error"})
       }


       fmt.Printf("%#v", v)


       owner := fmt.Sprintf("<h3>Account owner - %s</h3>", v.Name)
       role := fmt.Sprintf("<h3>Account role - %s</h3>", v.Role)
       footer := fmt.Sprintf("<h3>Account footer - %s</h3>", v.MetaData)


       return c.SendString(owner + role + footer)
   })


}

Для хендлера /login мы уже написали обработчик и разобрали его работу. Далее мы создаём “группу” /api . Теперь перед выполнением любой хендлера в этой группе будет выполняться наше middleware CheckAuth(), которое определяет токен аутентификации у пользователя. Далее в группу помещаем хендлер /account, также можете обратить внимание на то, как в хендлере account мы получаем объект из локального хранилища и затем используем значения его полей в конечном результате. 

Также добавим функцию Start для запуска нашего сервиса:

func (a *App) Start() error {
   return a.routerApi.Listen(a.config.Address)
}

Осталось написать код только для функции main. В этой функции мы читаем конфиг, создаем объект типа *fiber.App, создаём наше приложение и запускаем сервис.

package main


import (
   "github.com/gofiber/fiber/v2"
   "log"
   "pasetoservice/internal/api"
   "pasetoservice/internal/appconfig"
)


func main() {


   c, err := appconfig.Load(".env")
   if err != nil {
       log.Fatal("can't load config:", err)
   }


   routerApi := fiber.New()
   app, err := api.NewApp(c, routerApi)
   if err != nil {
       log.Fatal("create app error:", err)
   }
   app.SetApi()
   log.Fatal(app.Start())


}

Теперь проверим при помощи Postman работу нашего сервиса. Для начала получим токен для аутентификации. Для этого отправим POST запрос по адресу localhost:4444/login и в теле запроса разместим формы с логином и паролем пользователя. Пример запроса представлен на рисунке ниже:

В результате получим такой токен:

{
"token": "v4.local.v5fH8aPWCaV9VBd5HPnVlSU4kfEf8zHsCAAluI5-9jZsA93D_OLMfSKBJFgd4AUq_X2GjjzjnAZnbGVZiooQKJWV9Fd_YX7X1b5Egj_A7FkMp1CYByeYPFEhsp0CPaaytZdqKpJ5gmcF3TBzPQE9a2Y6Bk648Yc0RiI2cWBwbj8AI61zs0TfwpjvGbf8ENKv6TASBFZbVdrRYFDxizF2Hxgc35OJTXDy37jsS8lmOBhnR0dCJZFP1itfRgDnaNyPUQQbj0gwUteuq7J8cAElIcudvJthfaSPioGx_ibeXFHlKWaAikc1.eyJtZXRhX2RhdGEiOiJmb290ZXIgZm9yIGFkbWluIn0"
}

В данном токене присутствует футер (eyJtZXRhX2RhdGEiOiJmb290ZXIgZm9yIGFkbWluIn0), который закодирован в base64. Если мы его раскодируем, то получим такой результат:

{
"meta_data":"footer for admin"
}

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

После получения токена мы должны поместить его в заголовок Authorization. Если вы попытаетесь сделать запрос без него или поместите туда неправильный токен, то сервер вернет код 401 и ошибку.

После помещения токена в заголовок (не забудьте указать схему Bearer в заголовке) вы можете делать запросы к защищенному API. Результат представлен на рисунке ниже.

Посмотреть исходный код можно тут.

Автор статьи @yurii_habr


НЛО прилетело и оставило здесь промокод для читателей нашего блога:
-15% на заказ любого VDS (кроме тарифа Прогрев) — HABRFIRSTVDS.

Комментарии (0)