JWT и Go. Как их «‎подружить» с требованиями безопасности

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Привет! Я — Александр Бричак, Golang developer в NIX.

Используя общепринятые решения и технологии, разработчики часто не задумываются, какие риски несет в себе то или иное решение при неправильном использовании и подходит ли оно вообще для тех задач, к которым его пытаются применить. Это в полной мере относится к такой популярной технологии как JWT.

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

Почему именно Golang? Высокая производительность этого языка облегчает работу с высоконагруженным ПО и микросервисной архитектурой. Сфера его применения широкая, а синтаксис прост в изучении. Golang-комьюнити растет по всему миру. Поэтому NIX разработали бесплатную платформу обучения для новичков, где можно выучить Go и стартовать в IT c перспективного направления.

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

Как убедиться, что данные, которые получил сервер веб-приложения (backend или API), действительно отправлял тот или иной пользователь? Это помогает определить технология JSON Web Token. При использовании веб-токенов для доступа клиентских приложений к API всегда следует помнить, что токен может попасть в руки злоумышленников. Поэтому обычно после аутентификации пользователь получает не один токен, а два:

  • короткоживущий access-token. Его можно многократно использовать для получения ресурсов с сервера. Жизненный цикл токена отображается в payload-части и зачастую ограничивается часами, а то и минутами, в зависимости от приложения. Стандартные JWT-библиотеки при валидации токена по умолчанию проверяют, не просрочен ли он. В любом случае у получившего access-токен злоумышленника будет очень ограниченное время для действий от имени пользователя.
  • refresh-token с длительным сроком использования. Он позволяет обновить пару токенов после истечения срока действия access-токена.

Подобный механизм принят, в частности, в протоколе OAuth 2.0.

Во frontend-приложениях при использовании JWT схема работы будет такой:

1. Как только сервер вернул access- и refresh-токены в ответ на логин и пароль пользователя, система запоминает эту пару токенов

2. При каждом обращении к API frontend-приложение добавляет к HTTP-запросу заголовок с access-токеном. Если токен не просрочен, сервер возвращает ответ

3. Если access-токен просрочен, сервер откликается со статусом ошибки HTTP 401 Unauthorized. Чтобы получить новую пару токенов, приложению сначала нужно обратиться к специальному API-эндпойнту на сервере и передать refresh-токен. Затем повторить HTTP-запрос для получения данных уже со сгенерированным access-токеном.

В Javascript, например, такую механику удобно реализовать в библиотеке axios с помощью interceptors.

Как сделать токен невалидным и зачем это нужно

JSON Web-token изначально создавали как stateless-механизм для авторизации, то есть без потребности хранить на сервере информацию. Срок действия токена записывается автоматически. По истечению времени он просто становится невалидным и не принимается сервером. Такая схема хороша тем, что не требует дополнительных ресурсов сервера для запоминания состояния.

Представим, что нам требуется реализовать логаут — выход пользователя из приложения. На фронтенде это реализуется легко путем «‎забывания» пары токенов. Для продолжения работы с приложением пользователь снова должен ввести свой логин и пароль и получить новый набор токенов. Но что, если токен попал в руки злоумышленника? В случае кражи, если хакеру достался refresh-токен, у него будет предостаточно времени, чтобы натворить чего-нибудь от имени пользователя. При этом у реального юзера нет возможности отозвать токены и остановить злоумышленника. Спасет разве что блокировка пользователя на сервере или замена секретной строки, которой подписываются токены. После этой операции все выпущенные токены станут невалидными.

Поэтому RFC6749, описывающее протокол OAuth 2.0, требует дополнительных мер для идентификации неправомерного использования refresh-токена. В этом случае можно применить аутентификацию пользователя, приславшего этот токен. Другой способ — ротация refresh-токена, после чего он инвалидируется и запоминается на сервере. Если в будущем кто-то попытается воспользоваться им, это станет сигналом о возможном взломе.

Все эти соображения во многих случаях приводят к необходимости превращения stateless-токенов в stateful, т.е. хранения на сервере некой информации, позволяющей объявлять токены определенного пользователя невалидными. Тогда при каждом обращении пользователя сервер сначала проверяет валидность токена на основании информации, содержащейся в самом токене (в частности, срок действия), а потом — на основании информации на сервере.

Есть множество способов организации этого процесса, например:

  • хранить на сервере blacklist токенов. Список формируется после логаута или обновления пары токенов. При обращении на сервер с токеном из blacklist, юзер получит ошибку авторизации;
  • хранить на сервере blacklist пользователей. В нем может быть записан ID юзера и время логаута. Любые токены, выданные пользователю ранее, чем момент логаута, будут невалидными;
  • хранить на сервере информацию о выпущенных токенах с привязкой к ID пользователя. Токен, переданный пользовательским приложением в запросе к серверу, будет валидным, если его информация совпадает с данными о выпущенном для этого пользователя токене;

Способы «‎экзотические»:

  • создать для каждого пользователя секретные строки для подписания токенов. Это позволит менять строку для инвалидации токенов конкретного юзера;
  • изменить ID пользователя, если его токены скомпрометированы. После этого старые токены не будут соответствовать ни одному пользователю.

Для проверки токена многие из этих способов требуют дополнительный запрос к базе данных при каждом обращении пользователя к серверу. Чтобы снизить нагрузку на БД и ускорить обработку запроса, используют другие варианты хранения информации о токенах. Например, in-memory database.

Множество других идей вы можете почерпнуть здесь и здесь.

Автоматический логаут и JWT

Во многих пользовательских приложениях требуется реализовать автоматический логаут — отключение пользователя в случае неактивности в течение некоторого времени. Особенно функция касается приложений, предоставляющих доступ к персональным данным и другой «‎чувствительной» информации (банковским счетам или записям в истории болезни). В частности, в американских стандартах HIPAA такое требование применяется к приложениям, предоставляющим доступ к защищенной электронной медицинской информации пользователей (ePHI).

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

Основной поток взаимодействия фронтенд-приложения с внешним миром происходит через API на бэкенд-сервере. Поэтому активностью пользователя с его стороны можно считать выполнение запросов к API, а неактивностью — период между двумя запросами одного и того же пользователя. Задача бэкенд-сервера — отслеживать этот промежуток времени между запросами и применять принудительный логаут в случае превышения максимального периода неактивности.

Решение нашей команды с использованием stateful-токенов

Предлагаемый нами подход выходит за рамки stateless-токенов и предполагает хранение информации о выданных токенах на сервере — в Redis. Помимо ID пользователя, в токены мы добавляем еще один ID для сопоставления токена с информацией, записанной на сервере. В этой статье подробно описана такая схема работы с токенами.

Главная польза базы данных Redis — выполнение автоматического логаута. Благодаря механизму автоматической экспирации (окончания срока действия) данных в БД Redis, удалось наладить такой способ хранения и обновления информации о выданных токенах, при котором по истечению максимально разрешенного периода между запросами пользователя информация о его токенах сама удаляется из БД Redis. Токены становятся невалидными.

Для примера возьмем шаблонное приложение, написанное на Golang Web-фреймворке Echo. В нем уже реализована регистрация и логин пользователей, обновление пары токенов с помощью refresh-токена и есть набор тестов. Далее будем последовательно вносить в него изменения, чтобы получить желаемый результат. Также здесь есть Swagger-документация, которую удобно использовать для проверки наших изменений. Внесенные в код шаблонного приложения обновления доступны в репозитории в ветке «‎feature/JWT-logout».

Улучшаем шаблонное приложение

Для работы с JWT шаблонное приложение использует библиотеку dgrijalva/jwt-go. Помимо стандартного набора полей claims, эта библиотека позволяет описывать дополнительные поля. В приложении это дает возможность записывать в токен ID пользователя, которому он выдан. Библиотека поддерживает функции NewWithClaims() и Parse(), используемые в AuthHandler приложения для создания и проверки токенов. Также фреймворк Echo имеет middleware JWT, использующее указанную библиотеку для проверки токенов. Это middleware подключено в функции ConfigureRoutes() шаблонного приложения, объявляющей роутинг.

В текущей реализации шаблонного приложения применяются исключительно stateless-токены. В таком случае нет способа объявить токены невалидными до истечения срока их действия. Кроме невозможности полноценного логаута, это приводит к такому сценарию: с одним refresh-токеном можно несколько раз обратиться на API-эндпойнт /refresh. Наши дальнейшие изменения должны решить и эту проблему.

Перейдем к реализации наших идей. В базе данных Redis будем хранить определенную информацию о выпущенных токенах для каждого пользователя. Нам нужно добавить в код приложения следующие компоненты:

  1. подключение к БД Redis
  2. запись информации о выпущенных токенах в Redis при генерации пары токенов
  3. проверка существования токена в Redis для роутов, защищенных авторизацией
  4. удаление записей из Redis при обращении пользователя на API-эндпойнт /logout.

Redis connection

Поскольку наше шаблонное приложение использует docker-compose, мы легко можем добавить контейнер с БД Redis, объявив его в docker-compose.yml:

echo_redis:
 image: redis
 container_name: ${REDIS_HOST}
 restart: unless-stopped
 ports:
   - ${REDIS_EXPOSE_PORT}:${REDIS_PORT}
 networks:
   - echo-demo-stack

Для создания контейнера нам понадобится внести значения REDIS_HOST, REDIS_PORT, REDIS_EXPOSE_PORT в .env-файл. Для подключения к серверу Redis потребуется добавить структуру RedisConfig в пакет config:

package config

import "os"

type RedisConfig struct {
  Host string
  Port string
}

func LoadRedisConfig() RedisConfig {
  return RedisConfig{
     Host: os.Getenv("REDIS_HOST"),
     Port: os.Getenv("REDIS_PORT"),
  }
}

Затем — функцию InitRedis() в пакет db. Для подключения она использует библиотеку github.com/go-redis/redis.

func InitRedis(cfg *config.Config) *redis.Client {
  addr := fmt.Sprintf("%s:%s", cfg.Redis.Host, cfg.Redis.Port)

  return redis.NewClient(&redis.Options{
     Addr: addr,
  })
}

Функцию InitRedis() вызываем в методе NewServer() пакета server при запуске приложения:

func NewServer(cfg *config.Config) *Server {
  return &Server{
     Echo:   echo.New(),
     DB:     db.Init(cfg),
     Redis:  db.InitRedis(cfg),
     Config: cfg,
  }
}

Сохранение информации о токенах

Теперь у нас есть подключение к Redis, и мы можем заняться сохранением информации о выпущенных токенах. Для этого нам потребуется внести изменения только в код сервиса в пакете token. Будем сохранять не сам токен, а какой-нибудь уникальный идентификатор UID. Этот идентификатор также будет присутствовать в claims соответствующего токена. Разобрав токен, пришедший в запросе пользователя и сверив UID с тем, что хранится на сервере, мы всегда будем знать, является ли этот токен активным.

Добавим поле UID в JwtCustomClaims и в метод createToken():

type JwtCustomClaims struct {
  ID  uint   `json:"id"`
  UID string `json:"uid"`
  jwtGo.StandardClaims
}

UID будем создавать с помощью библиотеки github.com/google/uuid. Добавим также сгенерированный UID в список выходных параметров метода createToken():

func (tokenService *Service) createToken(userID uint, expireMinutes int, secret string) (
  token string,
  uid string,
  exp int64,
  err error,
) {
  exp = time.Now().Add(time.Minute * time.Duration(expireMinutes)).Unix()
  uid = uuid.New().String()
  claims := &JwtCustomClaims{
     ID:  userID,
     UID: uid,
     StandardClaims: jwtGo.StandardClaims{
        ExpiresAt: exp,
     },
  }

Теперь объявим структуру, которая будет сохраняться на сервере каждый раз при генерации пары токенов:

type CachedTokens struct {
  AccessUID  string `json:"access"`
  RefreshUID string `json:"refresh"`
}

Поскольку нашему сервису в пакете token понадобится подключение к Redis, изменим объявление сервиса и метод NewTokenService() следующим образом:

type Service struct {
  server *s.Server
}

func NewTokenService(server *s.Server) *Service {
  return &Service{
     server: server,
  }
}

Последнее изменение касается метода GenerateTokenPair(). Получив UID каждого созданного токена и записав эти UID в структуру CachedTokens, сохраним JSON этой структуры в Redis c ключом «‎token-{ID}», где вместо ID будет поставлен идентификатор пользователя, выполнившего вход в систему:

func (tokenService *Service) GenerateTokenPair(user *models.User) (
  accessToken string,
  refreshToken string,
  exp int64,
  err error,
) {
  var accessUID, refreshUID string
  if accessToken, accessUID, exp, err = tokenService.createToken(user.ID, ExpireAccessMinutes,
     tokenService.server.Config.Auth.AccessSecret); err != nil {
     return
  }

  if refreshToken, refreshUID, _, err = tokenService.createToken(user.ID, ExpireRefreshMinutes,
     tokenService.server.Config.Auth.RefreshSecret); err != nil {
     return
  }

  cacheJSON, err := json.Marshal(CachedTokens{
     AccessUID:  accessUID,
     RefreshUID: refreshUID,
  })
  tokenService.server.Redis.Set(fmt.Sprintf("token-%d", user.ID), string(cacheJSON), 0)

  return
}

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

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

Проверка существования токенов в Redis

Добавим в сервис в пакете token метод ValidateToken(). Этот метод извлекает из Redis данные о токенах, которые хранятся c ключом «‎token-{ID}». Вместо ID будет подставляться идентификатор пользователя из claims токена, присланного в запросе. Далее UID токена из запроса сравнивается с UID из Redis. Если они совпадают, значит, пользователь прислал валидный токен.

func (tokenService *Service) ValidateToken(claims *JwtCustomClaims, isRefresh bool) error {
  cacheJSON, _ := tokenService.server.Redis.Get(fmt.Sprintf("token-%d", claims.ID)).Result()
  cachedTokens := new(CachedTokens)
  err := json.Unmarshal([]byte(cacheJSON), cachedTokens)

  var tokenUID string
  if isRefresh {
     tokenUID = cachedTokens.RefreshUID
  } else {
     tokenUID = cachedTokens.AccessUID
  }

  if err != nil || tokenUID != claims.UID {
     return errors.New("token not found")
  }

  return nil
}

Будем вызывать его в методе RefreshToken() в AuthHandler:

func (authHandler *AuthHandler) RefreshToken(c echo.Context) error {
  refreshRequest := new(requests.RefreshRequest)
  if err := c.Bind(refreshRequest); err != nil {
     return err
  }

  claims, err := authHandler.tokenService.ParseToken(refreshRequest.Token,
     authHandler.server.Config.Auth.RefreshSecret)
  if err != nil {
     return responses.ErrorResponse(c, http.StatusUnauthorized, "Not authorized")
  }

  if authHandler.tokenService.ValidateToken(claims, true) != nil {
     return responses.MessageResponse(c, http.StatusUnauthorized, "Not authorized")
  }

  user := new(models.User)

Для этого нужно будет немного переделать метод ParseToken(), чтобы он возвращал не стандартный набор JWT claims, а ссылку на JwtCustomClaims, из которых мы сможем извлечь идентификатор токена:

func (tokenService *Service) ParseToken(tokenString, secret string) (
  claims *JwtCustomClaims,
  err error,
) {
  token, err := jwtGo.ParseWithClaims(tokenString, &JwtCustomClaims{},
     func(token *jwtGo.Token) (interface{}, error) {
        if _, ok := token.Method.(*jwtGo.SigningMethodHMAC); !ok {
           return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
        }
        return []byte(secret), nil
     })
  if err != nil {
     return
  }

  if claims, ok := token.Claims.(*JwtCustomClaims); ok && token.Valid {
     return claims, nil

И, конечно же, метод ValidateToken() должен вызываться для проверки во всех роутах, защищенных токеном. Для этого мы добавим еще одно middleware в файле auth.go:

func ValidateJWT(server *s.Server) echo.MiddlewareFunc {
  return func(next echo.HandlerFunc) echo.HandlerFunc {
     return func(c echo.Context) error {
        token := c.Get("user").(*jwtGo.Token)
        claims := token.Claims.(*tokenService.JwtCustomClaims)

        if tokenService.NewTokenService(server).ValidateToken(claims, false) != nil {
           return responses.MessageResponse(c, http.StatusUnauthorized, "Not authorized")
        }

        return next(c)
     }
  }
}

Затем используем его после встроенного middleware JWT при объявлении роутов в функции ConfigureRoutes():

    authMW := middleware.JWT(server.Config.Auth.AccessSecret)
    validateTokenMW := middleware.ValidateJWT(server)
    apiProtected := server.Echo.Group("")
    apiProtected.Use(authMW, validateTokenMW)

Поскольку встроенное JWT-middleware после проверки токена добавляет его в контекст запроса с ключом «‎user», наше дополнительное middleware для валидации токена может извлечь токен из контекста и работать с ним — запустить метод ValidateToken() сервиса в пакете token для проверки его данных в Redis.

Удаление информации о токенах при логауте

Чтобы реализовать логаут, осталось добавить код для удаления записи о токенах пользователя из Redis. Добавим метод Logout() в AuthHandler:

func (authHandler *AuthHandler) Logout(c echo.Context) error {
  user := c.Get("user").(*jwtGo.Token)
  claims := user.Claims.(*tokenservice.JwtCustomClaims)

  authHandler.server.Redis.Del(fmt.Sprintf("token-%d", claims.ID))

  return responses.MessageResponse(c, http.StatusOK, "User logged out")
}

Используем упрощенную проверку токена (без дополнительной валидации в Redis). Добавим роут «‎/logout» в функцию ConfigureRoutes():

authMW := middleware.JWT(server.Config.Auth.AccessSecret)
server.Echo.POST("/logout", authHandler.Logout, authMW)

validateTokenMW := middleware.ValidateJWT(server)

Автоматический логаут

Предположим, что перед нами стоит задача провести автоматический логаут пользователя в случае 10-минутной неактивности. Установкой срока действия access-токена задачу не решить. Если пользователь получил пару токенов и в следующий раз обратился к API через 11 минут, мы вернем статус 401 Unauthorized. Однако пользователь после этого может обратиться на эндпойнт /refresh и благодаря более длинному сроку действия refresh-токена получит новую пару токенов. Мы не можем этого допустить.

С другой стороны, установить срок в 10 минут и для refresh-токена тоже не вариант. В ситуации, когда юзер обратится к API через 9 минут после получения пары токенов, с этого момента мы должны стартовать новый отсчет времени для автоматического логаута и позволить пользователю обратиться к API (с access-токеном или с refresh-токеном для /refresh) не позднее, чем через 19 минут после получения первой пары токенов.

Как я уже отметил ранее, механизм удаления данных по истечению срока действия (TTL) в Redis очень удобен для решения этой проблемы.

Напомню, когда в методе GenerateTokenPair() мы записываем данные в Redis после создания токенов, третьим параметром в методе Redis.Set() указывается срок действия записи. По истечению этого срока Redis автоматически удаляет запись. Если мы передаем в качестве этого параметра 0, то запись будет иметь неограниченный TTL:

tokenService.server.Redis.Set(fmt.Sprintf("token-%d", user.ID), string(cacheJSON), 0)

Управляя TTL записи в Redis, мы добьемся автоматической инвалидации токенов по истечению заданного времени. В таком случае период автоматического логаута можно задать любым, независимо от срока действия токенов.

Что нужно сделать:

  1. задать TTL для записи в Redis в методе GenerateTokenPair() в 10 минут. Этот шаг сработает при первоначальном логине пользователя и при последующем обновлении пары токенов по /refresh.
  2. продлевать TTL этой записи еще на 10 минут каждый раз, когда пользователь обращается с успешным запросом к API.

Создадим константу const AutoLogoffMinutes = 10 и изменим параметр «expiration» в GenerateTokenPair():

    tokenService.server.Redis.Set(fmt.Sprintf("token-%d", user.ID), string(cacheJSON),
  time.Minute*AutoLogoffMinutes)

С помощью команды Redis Expire добавим продление TTL записи с токенами после успешной проверки ее существования в middleware ValidateJWT в файле auth.go:

func ValidateJWT(server *s.Server) echo.MiddlewareFunc {
  return func(next echo.HandlerFunc) echo.HandlerFunc {
     return func(c echo.Context) error {
        token := c.Get("user").(*jwtGo.Token)
        claims := token.Claims.(*tokenService.JwtCustomClaims)

        if tokenService.NewTokenService(server).ValidateToken(claims, false) != nil {
           return responses.MessageResponse(c, http.StatusUnauthorized, "Not authorized")
        }

        server.Redis.Expire(fmt.Sprintf("token-%d", claims.ID),
           time.Minute*tokenService.AutoLogoffMinutes)

        return next(c)
     }
  }
}

Допустим, мы задали период автоматического логаута при неактивности пользователя в течение 10 минут. Срок действия access-токена — 20 минут, refresh-токена — 60 минут. Механизм автоматического логаута отлично можно понять из схемы:

На первом этапе фронтенд-приложение присылает логин и пароль пользователя и получает ответ от API c access- и refresh-токенами. Запись с UID токенов помещается в Redis с TTL 10 минут.

На втором и третьем этапах приложение присылает различные API-запросы. Каждый из них отстает от предыдущего не более, чем на 10 минут. Каждый раз TTL записи в Redis с UID токенов передвигается на 10 минут. При этом сам срок действия токенов остается неизменным.

На четвертом этапе фронтенд-приложение присылает запрос к API по истечению 20 минут с момента генерации токенов и получает ответ 401 Not Authorized, т.к. access-токен просрочен. Обратившись с refresh-токеном на эндпойнт /refresh, фронтенд получает новый набор токенов. В Redis записывается информация о новых токенах со свежим TTL 10 минут. Старые токены уже невалидны.

На пятом этапе приложение присылает запрос к API через 12 минут после предыдущего этапа. Несмотря на то, что срок действия токенов не истек, запись в Redis удалилась по истечению TTL 10 минут. Фронтенд не сможет получить новые токены, пока заново не проведет логин пользователя. Таким образом, автоматический логаут выполнен.

Информация о пользователе

В нашем коде валидации токенов есть одна проблема. Предположим, что пользователь вошел в систему, и информация о его токенах хранится в Redis. Сразу после этого он по каким-то причинам был инактивирован (например, администратор системы удалил запись пользователя из БД или присвоил ему статус «‎inactive»). Мы должны убедиться, что приложение пользователя больше не сможет работать с API, используя выданный набор токенов. В тот момент, когда администратор инактивирует юзера, информация о токенах этого пользователя должна автоматически удаляться из Redis. Но что, если это забыли сделать?

Чтобы избежать таких проблем, мы можем при валидации токена проверять не только наличие записи в Redis, но и наличие/активность записи самого пользователя в базе данных. Это требует дополнительного запроса к БД.

С другой стороны, в процессе обработки запроса зачастую все равно происходит поиск записи пользователя в базе данных:

  • серверу нужна информация о текущем пользователе. Она поможет определить права на совершение определенных действий;
  • при проведении запросов, изменяющих данные в БД, код бэкенд-приложения должен проверить, что запись пользователя существует в БД, и юзер не инактивирован.

Для реализации этой идеи добавим в метод ValidateToken() сервиса token код для поиска записи пользователя в БД. Также добавим найденную запись пользователя в список возвращаемых параметров указанного метода:

func (tokenService *Service) ValidateToken(claims *JwtCustomClaims, isRefresh bool) (
  user *models.User,
  err error,
) {
  cacheJSON, _ := tokenService.server.Redis.Get(fmt.Sprintf("token-%d", claims.ID)).Result()
  cachedTokens := new(CachedTokens)
  err = json.Unmarshal([]byte(cacheJSON), cachedTokens)

  var tokenUID string
  if isRefresh {
     tokenUID = cachedTokens.RefreshUID
  } else {
     tokenUID = cachedTokens.AccessUID
  }

  if err != nil || tokenUID != claims.UID {
     return nil, errors.New("token not found")
  }

  user = new(models.User)
  userRepository := repositories.NewUserRepository(tokenService.server.DB)
  userRepository.GetUser(user, int(claims.ID))
  if user.ID == 0 {
     return nil, errors.New("user not found")
  }

  return user, nil
}

Метод GetUser() репозитория может извлекать не только запись пользователя из таблицы users, но и в одном JOIN-запросе получать личные данные и роли пользователя из таблиц user_details, user_roles и других (если такие таблицы есть в БД и эта информация пригодится для обработки запроса). Указанные изменения позволят нам убрать код для проверки записи пользователя из метода RefreshToken():

func (authHandler *AuthHandler) RefreshToken(c echo.Context) error {
  refreshRequest := new(requests.RefreshRequest)
  if err := c.Bind(refreshRequest); err != nil {
     return err
  }

  claims, err := authHandler.tokenService.ParseToken(refreshRequest.Token,
     authHandler.server.Config.Auth.RefreshSecret)
  if err != nil {
     return responses.ErrorResponse(c, http.StatusUnauthorized, "Not authorized")
  }

  user, err := authHandler.tokenService.ValidateToken(claims, true)
  if err != nil {
     return responses.MessageResponse(c, http.StatusUnauthorized, "Not authorized")
  }

  accessToken, refreshToken, exp, err := authHandler.tokenService.GenerateTokenPair(user)
  if err != nil {
     return err
  }
  res := responses.NewLoginResponse(accessToken, refreshToken, exp)

  return responses.Response(c, http.StatusOK, res)
}

В коде middleware ValidateJWT будет более существенное изменение. Добавим найденную запись пользователя в контекст запроса с ключом «‎currentUser», давая возможность обращаться к этой информации на всех последующих этапах обработки запроса:

// Middleware for additional steps:
// 1. Check the user exists in DB
// 2. Check the token info exists in Redis
// 3. Add the user DB data to Context
// 4. Prolong the Redis TTL of the current token pair
func ValidateJWT(server *s.Server) echo.MiddlewareFunc {
  return func(next echo.HandlerFunc) echo.HandlerFunc {
     return func(c echo.Context) error {
        token := c.Get("user").(*jwtGo.Token)
        claims := token.Claims.(*tokenService.JwtCustomClaims)

        user, err := tokenService.NewTokenService(server).ValidateToken(claims, false)
        if err != nil {
           return responses.MessageResponse(c, http.StatusUnauthorized, "Not authorized")
        }

        c.Set("currentUser", user)

        server.Redis.Expire(fmt.Sprintf("token-%d", claims.ID),
           time.Minute*tokenService.AutoLogoffMinutes)

        return next(c)
     }
  }
}

Оптимизация кода ValidateToken()

Обратим внимание, что в методе ValidateToken() пакета token происходят два последовательных действия:

  • извлечение записи с информацией о токенах из Redis;
  • извлечение информации о пользователе из БД.

Golang позволяет нам выполнить эти запросы параллельно. Мы незначительно сэкономим время обработки запроса (фактически — только на время, необходимое на извлечение и парсинг записи Redis в структуру Golang). Но когда можно оптимизировать код, почему бы этого не сделать?

Используем пакет golang.org/x/sync/errgroup. Он позволит запустить несколько горутин и дождаться их успешного выполнения. Однако, в случае ошибки хотя бы в одной из них, выполнение всей группы будет отменено. Код метода ValidateToken() примет такой вид:

func (tokenService *Service) ValidateToken(claims *JwtCustomClaims, isRefresh bool) (
  user *models.User,
  err error,
) {
  var g errgroup.Group
  g.Go(func() error {
     cacheJSON, _ := tokenService.server.Redis.Get(fmt.Sprintf("token-%d", claims.ID)).Result()
     cachedTokens := new(CachedTokens)
     err = json.Unmarshal([]byte(cacheJSON), cachedTokens)

     var tokenUID string
     if isRefresh {
        tokenUID = cachedTokens.RefreshUID
     } else {
        tokenUID = cachedTokens.AccessUID
     }

     if err != nil || tokenUID != claims.UID {
        return errors.New("token not found")
     }

     return nil
  })

  g.Go(func() error {
     user = new(models.User)
     userRepository := repositories.NewUserRepository(tokenService.server.DB)
     userRepository.GetUser(user, int(claims.ID))
     if user.ID == 0 {
        return errors.New("user not found")
     }

     return nil
  })

  err = g.Wait()

  return user, err
}

Еще одна небольшая оптимизация ждет в middleware ValidateJWT. Продление TTL записи с информацией от токенах в Redis можно также выполнять в горутине. Так дальнейшая обработка запроса не будет заблокирована в то время, пока мы ждем окончания этой операции:

c.Set("currentUser", user)

go func() {
  server.Redis.Expire(fmt.Sprintf("token-%d", claims.ID),
     time.Minute*tokenService.AutoLogoffMinutes)
}()

return next(c)

Все ли мы сделали правильно?

Если посмотреть на итоговый код, то видно, что мы все равно делаем запрос к основной базе данных при проведении проверки существования пользователя. Значит, мы могли бы хранить в этой БД информацию о токенах юзера и дате их последнего использования, а также реализовать логаут и автоматический логаут без использования БД Redis. Почему именно Redis?

  • это позволяет разгрузить основную БД от хранения несвойственных ей данных и лишних запросов (информация о токенах и моменте последнего обращения пользователя к API носит, скорее, краткосрочный характер);
  • механизм автоматического удаления записей с истекшим TTL позволяет более изящно реализовать автоматический логаут и не занимать место на сервере БД для хранения просроченной информации;
  • в Redis можно хранить и другие данные из БД. Например, информацию о пользовательских ролях и пермиссиях.

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

Как хранить токены на клиенте

Включение токенов в тело ответа при процедуре логина приводит зачастую к тому, что фронтенд-разработчики принимают решение хранить полученные токены в локальном хранилище браузера. Это помогает избежать необходимости повторного логина при принудительном обновлении страницы или открытии новой вкладки пользователем. Решение очень уязвимо к XSS-атакам, в процессе которых код злоумышленника может получить доступ к локальному хранилищу.

Часто используется альтернативный вариант, при котором access-токен передается в теле ответа и хранится далее в памяти фронтенд-приложения, а refresh-токен помещается в HttpOnly куки. Этот подход помогает лучше защититься от XSS-атак, но вместе с тем уязвим к CSRF-атакам. Подробнее можно прочитать в этом документе.

Подход с размещением refresh-токена в куки в идеале предполагает также изменение архитектуры бэкенд-приложения, при котором сервис авторизации находится в отдельном домене. Таким образом, куки с refresh-токеном будут передаваться только при взаимодействии с сервисом авторизации.

А как же сессия?

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

Подробнее об этом также можно узнать в статье Stop using JWT for sessions, а также в этом видео. Для иллюстрации процесса хочу привести схему рассуждений из продолжения упомянутой статьи. Здесь автор, пусть и в слегка саркастичной форме, показывает, как любые попытки решить проблему инвалидации токенов приводят просто к «‎изобретению» сессии:

Механизм сессий является одним из подходов к решению задачи логаута и автоматического логаута. Самый простой вариант такого механизма — добавить куки с определенной строкой к ответу сервера. В качестве строки может быть время ответа сервера, подписанное секретным ключом. При следующем обращении фронтенд-приложения с запросом сервер сравнит время предыдущего запроса, содержащееся в куки, с текущим временем. Если с момента предыдущего запроса прошло более заданного количества минут, пользователь получит статус ошибки HTTP 401 Unauthorized. Таким образом, access-токен будет действителен только в паре с куки, где информация о сессии пользователя.

Но это не снимает вопроса безопасности в случае атак. Поэтому для улучшения механизма сессий следует использовать другие способы хранения сессионной информации (в основной БД, в дополнительной in-memory БД, в файловой системе сервера и т.д.).

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

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

👍НравитсяПонравилось11
В избранноеВ избранном8
LinkedIn
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Чего люди не придумают лишь бы нормально систему не проектировать изначально.

Если у вас требования таковы что нужна сессия — делаете сессию (ваш случай).

А не пытаетесь из jwt делать сессию...

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

Ну короче говоря, механизм для логаута реализуется через хранение в токене помимо user ID, ещё SID — session ID, это может быть целое число, которое увеличивается на 1 после каждого логаута или сброса сессии, и хранится в базе вместе с инфой об аккаунте юзера. Если юзер присылает на бэкенд валидный токен с неактивным SID, выдаёт 401 и сообщение о старой сессии.

Фронтэнд в свою очередь может декодировать payload токена, и смотреть время его действия, чтоб не посылать лишние запросы, на которые заведомо придёт 401.

Что касается хранения в базе списка самих токенов, их уникальных идентификаторов, или их блэклистов — это ламерский подход, основанный на непонимании смысла появления токенов.

По сути, Ваш подход мало чем отличается от предложенного в статье. Какой-то ID, содержащийся в токене и в БД, позволяющий валидировать конкретный токен...

На самом деле главный смысл токенов в возможности горизонтального масштабирования на группу сервисов с ограниченными правами доступа. То есть, чтобы какой-либо сторонний сервис мог проверить авторизацию без обращения к приватной базе данных, к которой ему не положен доступ. При этом, токены могут создаваться не на основе хэша, а на основе электронной подписи, и в этом случае сервис может быть вообще абсолютно посторонним, и проверять авторизацию на основе только одного публичного ключа электронной подписи. Поэтому, введение подобных идентификаторов сессии для логаута и какой-либо логики по обращению к БД — противоречит главной идее работы с токенами. В принципе конец статьи к этим выводам и подводит.

В чём принципиальная разница между stateless-токеном и sataeful? Токен — он и в Африке токен, и попытка на него повесить ещё что-либо сродни Украины с её «электронным» паспортом к которому ещё нужно бумажку приносить.

То что инфа кешируется — классика жанра. То что инфа кешируется по токену — уже попаболь, и без анальной свечки здесь на обойдётся. Никаких «забыли» инвалидировать, нужен чёткий механизм инвалидации, написанный руками. И задача в точности та же самая, как при апдейте любой инфы. Ну не бывает нормальных кешей без способа инвалидации.

Дальше больше: а в чём была печаль авторизировать всё постоянным токеном? Нужны временные токены, которые будут генериться от постоянного, если это только не API KEY, где вообще кешировать нельзя ничего кроме трафика. Например, каждый час генерить новый. Да, да, нужно больше токенов богу токенов!

В этом и есть смысл токенов — давать их на каждый чих свои. И поверьте, проще иметь 100 лишних стрингов чем 1 проблему безопасности.

Круто! Но хотелось бы понять общую картину без технических деталей:
1. Что мы защищаем в итоге?
2. От кого/чего? Векторы?
3. По какому стандарту/классу?

Я не рассматриваю определенные виды атак.
Здесь идет речь в целом о необходимости дополнительного механизма инвалидации токенов при использовании JWT.

может лучше использовать keycloak?

Если нужно много кастомизаций, взаимодействие с Keycloak превращается в адок.

Проще уже использовать облачный провайдер, типа Okta, Auth0 или Cognito

Ну если требования иметь внутри у себя, то gluu или keycloak всяко безопасней поделки.
Где то забыли audience проверить или нужно поменять на hmac/rsa. Или openid connect чтобы было как у людей.

Интересная статья, спасибо !

© обращение Стерненко к судье

Подписаться на комментарии