GraphQL

JWT (JSON Web Token, произносится как ‘jot’ [джот]) становится популярным способом управления аутентификацией. Эта статья ставит целью развенчать мифы о стандарте JWT, рассмотреть его плюсы и минусы, а также познакомиться с лучшими методиками реализации JWT на стороне клиента с учётом безопасности. Несмотря на то, что здесь мы работали над примерами с помощью клиентов GraphQL, используемые принципы применимы к любому другому фронтенд-клиенту. 

Знакомство: что такое JWT?

В целях аутентификации JWT выступает в роли токена, выдаваемого сервером. Этот токен содержит относящееся к конкретному пользователю информационное наполнение в формате JSON. Клиенты могут использовать токен при взаимодействии с API (отправляя его как HTML-заголовок), чтобы API могли идентифицировать пользователя и выполнить соответствующее ему действие.

Но разве не может клиент просто создать случайное информационное наполнение и выдать себя за пользователя?

Хороший вопрос! Именно поэтому JWT также содержит подпись, которая создаётся сервером, выдавшим токен (предположим, конечной точкой вашей авторизации в системе). Любой другой сервер, получающий этот токен, может независимо проверить подпись, чтобы убедиться в подлинности информационного наполнения JSON и в том, что это наполнение было создано уполномоченным источником.

Но что если у меня есть действительный и подписанный JWT, а кто-то украдёт его из клиента? Смогут ли они постоянно использовать мой JWT?

Да. Если JWT будет украден, то вор сможет продолжать его использовать. API, принимающий JWT, выполняет самостоятельную проверку, не зависящую от его источника, следовательно сервер API никак не может знать о том, что этот токен был украден. Именно поэтому у JWT есть значение срока годности, которое намеренно создается коротким, и довольно часто его длительность определяется всего в 15 минут. Но при этом также нужно следить за тем, чтобы не произошло утечки JWT.

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

Именно поэтому очень важно не хранить JWT на клиенте, например в куки или локальном хранилище. Поступая так, вы делаете своё приложение уязвимым для атак CSRF и XSS, которые при помощи вредоносных форм или скриптов могут использовать или украсть ваш токен, благоприятно размещённый в куки или локальном хранилище.

Есть ли у JWT какая-то конкретная структура?

В сериализованном виде JWT выглядит примерно так:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.XbPfbIHMI6arZ3Y922BhjWgQzWXcXNrz0ogtVhfEd2o

Если вы раскодируете этот base64, то получите JSON в виде 3 важных частей: заголовка, информационного наполнения и подписи.

3 части JWT

Сериализованная форма будет иметь следующий формат:

[ base64UrlEncode(header) ] . [ base64UrlEncode(payload) ] . [signature ]

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

Вот упрощённая схема, демонстрирующая, как JWT выдаётся (/login), а затем используется для совершения вызова API к другому сервису ( /api):

Рабочий процесс выдачи и использования JWT

Выглядит сложновато. Почему бы мне не придерживаться старых добрых токенов сессии?

Это уже наболевшая тема интернет-форумов. Мы коротко и категорично отвечаем, что бэкенд-разработчикам нравится использовать JWT по следующим причинам:

 a) микросервисы;

 b) отсутствие необходимости в централизованной базе данных токенов. 

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

Именно поэтому разработчики API ценят JWT, а мы (с клиентской стороны) должны разобраться, как его использовать. Как бы то ни было, если вы можете обойтись токеном сессии, выданным вашим любимым монолитным фреймворком, то всё у вас в порядке и нет нужды в JWT.

Вход в систему

Теперь, когда у нас есть базовое понимание JWT, давайте создадим простой процесс входа в систему и извлечём JWT. Вот что у нас должно получиться:

Процесс входа в систему для получения JWT

С чего же начать?

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

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

Вот как может выглядеть обработчик handleSubmit для кнопки входа в систему:

async function handleSubmit () {
  //...
  // Вызов API login
  const response = await fetch(`/auth/login`, {
    method: 'POST',
    body: JSON.stringify({ username, password })
  })
  //...
  // Извлечение из ответа JWT 
  const { jwt_token } = await response.json()
  //...
  // Выполнение каких-либо действий с токеном в методе login
  await login({ jwt_token })
}

API login возвращает токен и затем передаёт его в функцию login из /utils/auth, где мы можем решить, что с ним делать.

import { login } from '../utils/auth'
await login({ jwt_token })

Итак, мы получили токен, где же нам теперь его хранить?

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

Может сохранить его в куки?

Куки, созданная в клиенте для сохранения JWT, также будет уязвима для XSS. Если JWT может быть считан на клиенте из JavaScript вне вашего приложения — то может быть и украден. Вы можете подумать, что поможет куки HttpOnly (создаваемая сервером, а не клиентом), но куки уязвимы к CSRF-атакам. Важно отметить, что HttpOnly, а также чувствительные политики CORS не могут предотвратить CSRF-атаки входа, и использование куки требует надлежащей стратегии по борьбе с ними.

Обратите внимание, что новая спецификация SameSite Cookie, которая получает повышенную поддержку в большинстве браузеров, будет создавать куки на основе подходов, защищённых от CSRF-атак. Это решение может не сработать, если ваши серверы Auth и API размещены на разных доменах, но в противном случае такой вариант должен вполне подойти. 

Где же тогда нам его хранить?

На данный момент мы будем хранить токен в памяти (и перейдём к постоянным сеансам в следующем разделе).

let inMemoryToken;

function login ({ jwt_token, jwt_token_expiry }, noRedirect) {
  inMemoryToken = {
    token: jwt_token,
    expiry: jwt_token_expiry
  };
  if (!noRedirect) {
    Router.push('/app')
  }
}

Как вы можете видеть, здесь мы храним токен в памяти. Да, он будет обнулён, когда пользователь переключит вкладки, но мы решим этот вопрос позже. Я также объясню, почему установил флаги noRedirect и jwt_token_expiry.

Теперь, когда у нас есть токен, что нам с ним делать?

  • Использовать его в нашем клиенте API для передачи в качестве заголовка каждого вызова API.
  • Проверять авторизован ли пользователь, посмотрев, определена ли переменная JWT.
  • По желанию мы даже можем декодировать JWT на клиенте, чтобы обратиться к данным информационного наполнения. Предположим, нам нужен id или имя пользователя на клиенте, которые мы можем извлечь из JWT. 

Как нам проверить, авторизован ли пользователь?

Мы проверяем в utils/auth, установлена ли переменная токена и если нет — перенаправляем на страницу авторизации.

const jwt_token = inMemoryToken;
if (!jwt_token) {
  Router.push('/login')
}
return jwt_token

Настройка клиента

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

Использование JWT на клиенте GrahQL

Предполагая, что ваш GraphQL API принимает JWT-токен аутентификации в качестве заголовка Authorization, вам нужно только настроить клиент на установку заголовка HTTP с использованием JWT-токена из переменной.

Вот как выглядит настройка с клиентом Apollo GraphQL, использующим мидлвар ApolloLink.

let appJWTToken
 const httpLink = new HttpLink({url: 'https://graphql-jwt-tutorial.herokuapp.com/v1/graphql'})
 const authMiddleware = new ApolloLink((operation, forward)=> {
  if (appJWTToken) {
    operation.setContext({
      headers: {
        Authorization: `Bearer ${appJWTToken}`
      }
    });
  } 
  return forward(operation);
 })

const apolloClient = new ApolloClient({
  link: concat(authMiddleware, httpLink),
  cache: new InMemoryCache(),
});

Как видно из этого кода, если есть токен, то он передаётся каждому запросу в качестве заголовка.

Но что случится, если токена не будет?

Всё зависит от потока выполнения в вашем приложении. К примеру, вы можете перенаправлять пользователя назад на страницу авторизации:

else { Router.push('/login')}

А если токен истекает в процессе его использования?

Предположим, наш токен действителен только 15 минут. В этом случае при его истечении мы, вероятно, получим ошибку от API, отрицающего наш запрос (например, 401: Unauthorized). Помните, что каждый сервис, умеющий использовать JWT, может независимо верифицировать его и проверить, истёк ли срок действия.

Давайте добавим в наше приложение обработку ошибок, чтобы разобраться с подобными случаями. Мы напишем код, который будет выполняться для каждого ответа API, проверяя его на ошибку. Когда мы получим от API ошибку об истечении/недействительности токена, мы запустим процесс выхода из системы или перенаправимся к процессу входа в неё.

Вот как выглядит код, если мы используем клиент Apollo:

import { onError } from 'apollo-link-error';

const logoutLink = onError(({ networkError }) => {
 if (networkError.statusCode === 401) logout();
})

const apolloClient = new ApolloClient({
  link: logoutLink.concat(concat(authMiddleware, httpLink)),
  cache: new InMemoryCache(),
});

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

Выход из системы

При использовании JWT “logout” просто стирает токен на стороне клиента, после чего он уже не может быть использован в последующих вызовах API.

Значит вызова API /logout не существует?

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

Что делать, если мне нужно обеспечить невозможность продолжения использования токена?

Поэтому важно определять краткосрочные значения срока действия JWT. По этой же причине нам ещё более важно обеспечить таким образом защиту JWT от кражи. Токен действителен (даже после его удаления на клиенте), но только в течение короткого промежутка времени, что снижает вероятность его использования злоумышленниками.

Дополнительно вы можете добавить процесс обработки чёрных списков. В данном случае можно создать вызов API /logout, и ваш сервер аутентификации будет помещать токены в “недействительный список”. Как бы то ни было, все API сервисы, использующие JWT, теперь должны добавить дополнительный шаг к их верификации, чтобы проверять централизованный “чёрный список”. Это снова вводит центральное состояние и возвращает нас к тому, что мы имели до использования JWT.

Разве наличие чёрного списка не исключает преимущество JWT, гласящее о необязательности центрального хранилища?

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

Что произойдёт, если я авторизован на нескольких вкладках?

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

window.addEventListener('storage', this.syncLogout) 

//....


syncLogout (event) {
  if (event.key === 'logout') {
    console.log('logged out from storage!')
    Router.push('/login')
  }
}

Теперь при выходе нам нужно совершить два действия:

  1. обнулить токен;
  2. установить в локальном хранилище элемент logout.
async function logout () {
  inMemoryToken = null;
  const url = 'http://localhost:3010/auth/logout'
  const response = await fetch(url, {
    method: 'POST',
    credentials: 'include',
  })
  // для поддержки выхода из системы во всех окнах
  window.localStorage.setItem('logout', Date.now())
}

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

Это работает для вкладок, но как мне принудительно закрыть сессии на разных устройствах?

Эту тему мы рассмотрим в одном из следующих разделов.

Фоновое обновление

Существует две главных проблемы, с которыми могут столкнуться пользователи нашего JWT-приложения:

  1. Учитывая короткий срок действия JWT, пользователь будет повторно авторизовываться каждые 15 минут, что было бы ужасно неудобно. Ему наверняка было бы комфортнее оставаться в системе длительное время. 
  2. Если пользователь закрывает своё приложение и вновь его открывает, то ему придётся авторизовываться повторно. Его сессия не постоянна, т.к. мы не сохраняем токен на клиенте. 

Для решения этих проблем большинство JWT-провайдеров предоставляют токен обновления, который имеет два свойства:

  1. Он может использоваться для совершения вызова API (например, /refresh_token) с запросом нового JWT-токена до истечения срока действия текущего. 
  2. Он может быть безопасно сохранён на клиенте в течение нескольких сессий.

Как работает обновляемый токен?

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

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

Как осуществляется безопасное хранение токена обновления на клиенте?

Токен обновления отправляется сервером аутентификации клиенту в виде куки HttpOnly и автоматически пересылается браузером в вызове API /refresh_token.

Т.к. JavaScript на клиентской стороне не может считать или украсть куки HttpOnly, это несколько лучше для противостояния XSS, чем хранение токена в обычной куки или локальном хранилище.

Такой подход защищён от CSRF-атак, т.к. даже несмотря на то, что атака на подписание формы может выполнить API-вызов /refresh_token, атакующий не сможет получить новое возвращаемое значение токена.

Какой же из способов сохранения JWT-сессии в итоге мы считаем лучшим?

Ответить можно в виде следующего уравнения: сохранение JWT-токена в локальном хранилище (уязвимом для XSS)<сохранение JWT-токена в куки HttpOnly (уязвимо для CSRF и менее подвержено XSS)<сохранение токена обновления в куки HttpOnly (защищено от CSRF и менее подвержено XSS).

Обратите внимание, что хоть этот метод и не устойчив к серьёзным XSS-атакам, объединённый с обычными техниками защиты от XSS, куки HttpOnly является рекомендуемым способом сохранения относящейся к сессии информации. При этом, сохраняя нашу сессию косвенно через токен обновления, мы устраняем прямую уязвимость CSRF, которая присутствует при использовании JWT-токена.

Как же выглядит новый процесс “авторизации”?

Ничего особо не меняется, за исключением того, что токен обновления отправляется вместе с JWT. Давайте ещё раз взглянем на диаграмму процесса авторизации, но теперь уже с функциональностью refresh_token:

Авторизация с обновляемым токеном
  1. Пользователь авторизуется с вызовом API login.
  2. Сервер генерирует JWT-токен и refresh_token.
  3. Сервер устанавливает куки HttpOnly с refresh_token. jwt_token и jwt_token_expiry возвращаются назад клиенту в виде информационного наполнения JSON.
  4. jwt_token хранится в памяти.
  5. Отсчёт до следующего фонового обновления начинается на основе jwt_token_expiry.

А как выглядит фоновое обновление?

Процесс фонового обновления

Вот что происходит:

  1. Вызов конечной точки /refresh_token
  2. Сервер будет считывать куки HttpOnly, и если он найдёт действительный refresh_token, тогда…
  3. …сервер вернёт новый jwt_token и jwt_token_expiry клиенту, а также установит новую куки токена обновления через заголовок Set-Cookie.

Постоянные сессии

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

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

Приложения обычно спрашивают пользователей, хотят ли те “stay logged in” (оставаться авторизованными) в течение нескольких сессий или сохраняют их авторизованными по умолчанию. Именно это хотим реализовать и мы.

На данный момент мы не можем этого сделать, т.к. JWT находится в памяти и не сохраняется. Ранее мы уже рассматривали, почему не можем хранить JWT непосредственно в куки или локальном хранилище. 

Как же можно безопасно сохранять сессии?

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

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

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

Возможный случай ошибки:

В случае если наш срок действия токена обновления истечёт (предположим, пользователь вернётся в приложение через большой промежуток времени) или будет пересоздан (к примеру, из-за принудительного выхода), тогда клиент получит ошибку 401 для неавторизованного refresh_token. Ещё в одном случае у нас может просто изначально не быть refresh_token, что также приведёт к ошибке, полученной от конечной точки /refresh_token, в результате которой пользователь будет перенаправлен на экран авторизации.

Вот пример кода, показывающий, как мы можем обработать эти ошибки при помощи logoutLink:

const logoutLink = onError(({ networkError }) => {
 if (networkError.statusCode === 401) logout();
})

Принудительный выход или выход из всех сессий на всех устройствах

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

Реализации токена обновления из предыдущих разделов показывают, что мы можем сохранять сессии и оставаться в системе.

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

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

Отображение серверной части (SSR)

В отображении серверной стороны при работе с JWT-токенами есть дополнительные трудности.

Вот что нам нужно:

  1. Браузер делает запрос к URL приложения.
  2. SSR-сервер отображает страницу на основе идентичности пользователя.
  3. Пользователь получает отображённую страницу, а затем продолжает использовать приложение в качестве SPA (одностраничного приложения).

Откуда SSR сервер узнаёт, авторизован ли пользователь?

Браузер должен отправить SSR-серверу информацию о текущей идентичности пользователя. Единственный способ сделать это — использовать куки.

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

Внимание! Для SSR на аутентифицированных страницах очень важно, чтобы домен API аутентификации (и, следовательно, домен куки refresh_token) совпадал с доменом SSR-сервера. В противном случае наши куки не смогут быть отправлены SSR-серверу.

Вот что делает SSR-сервер:

  1. При получении запроса на отображение конкретной страницы он захватывает куки refresh_token. 
  2. Далее сервер использует эту куки, чтобы получить новый JWT для пользователя.
  3. Затем сервер использует этот новый токен и совершает все аутентифицированные запросы GraphQL для извлечения правильных данных.

Может ли пользователь продолжить выполнение аутентифицированных API-запросов, когда SSR-страница будет загружена?

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

Если код нашего приложения попытается использовать этот токен обновления для запроса нового JWT, этот запрос провалится, и пользователь будет выброшен из системы. 

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

Весь поток SSR от начала до конца:

Пример кода

Пример кода для этой статьи с полным описанием рабочего приложения и возможностями SSR доступен здесь

Этот репозиторий также содержит пример бэкенд-кода аутентификации (основанного на Awesome https://github.com/elitan/hasura-backend-plus).

Итоги

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

Читайте также:


Перевод статьи VLADIMIR, TANMAI GOPAL: The Ultimate Guide to handling JWTs on frontend clients (GraphQL)