Yandex.Metrika Counter

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

Проект реализован на Vue.js, авторизация — с помощью JSON Web Token и интерцепторов Axios, которые отслеживают валидность токенов. Когда приходило время обновления токена, и пользователь переходил на новую страницу, к серверу отправлялись несколько запросов. Между ними возникало состояние гонки — race condition. Первый запрос приводил к обновлению токена, а остальные доходили до сервера с невалидными данными в заголовке. Итог — разлогин пользователя. Сегодня расскажу, как этого избежать. 

Начнем с принципов работы веб-токенов и сложностей с ними. Если вы с этим уже знакомы, можете сразу переходить к разделам о race condition и синхронизации  запросов через переменную с promise.

Что такое JWT, и почему их используют для авторизации?

JSON Web Token (JWT) — это открытый стандарт для создания токенов доступа, основанный на JSON. Токены создаются сервером, подписываются секретным ключом и передаются клиенту. Клиентское приложение использует токен для подтверждения своей личности.

Зачем нужны токены

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

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

Преимущества JWT перед cookie

Почему бы не использовать старые добрые cookie и вообще не заботиться об обновлении и подтверждении авторизации? Тому есть несколько причин. Но особенно я бы выделил три: 

  • Cookie привязана к домену, поэтому ее нельзя использовать в кросс-доменных запросах. При микросервисной архитектуре сервисы зачастую располагаются на разных доменах.
  • Cookie работает сразу только в браузере. Для консольных или нативных приложений для смартфонов у cookie нет поддержки из коробки. С токенами удобно работать в любом из вышеперечисленных случаев.
  • Используя JWT, мы видим проблему с безопасностью и стараемся предусмотреть механизмы контроля в случае кражи авторизационных данных. Используя cookie, программист зачастую даже не задумывается, что сессия может быть скомпрометирована, и рассчитывает на механизмы, предоставленные фреймворком.

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

Состав токена

JWT — это строка из трех частей: заголовка, полезных данных и подписи.

Заголовок содержит алгоритм хеширования и тип токена. 

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

Подпись формируется сервером на основе заголовка и полезных данных.

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

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

Пара токенов для лучшей защиты

В некоторых схемах авторизации используется два токена: access и refresh.

Access token используется при запросах к серверу. Он добавляется в заголовок каждого запроса к API. Access token многоразовый и короткоживущий. В нашем случае токен живет 10 минут с момента выдачи.

Refresh token используется для обновления пары токенов access и refresh. Он одноразовый и долгоживущий. Как только система использует refresh token, пользователю назначается новая пара, а старая отзывается. Наш refresh token действует 3 суток.

Пара токенов ограничивает время, на которое атакующий может получить доступ к сервису.

Как пара токенов ограничивает время доступа атакующего

Объясню на примере.

Случай 1: Боб узнал оба токена Алисы и не воспользовался refresh.

В этом случае Боб получит доступ к сервису на время жизни access token — на 10 минут. Как только оно истечет, и приложение, которым пользуется Алиса, воспользуется refresh token, сервер вернет новую пару токенов. А те, что узнал Боб, перестанут работать.

Случай 2: Боб узнал оба токена Алисы и воспользовался refresh.

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

Использование токенов на фронте

При авторизации в приложении пользователь получает пару токенов. Мы дехешируем их и сохраняем в хранилище Vuex. Для общения с сервером мы используем http-клиент Axios. Мы добавляем access token в заголовок Authorization нашего клиента. Этот заголовок будет подтверждать авторизацию пользователя, пока токен не устареет.

Сконфигурируем модуль пользователя в хранилище:

// @/store/user.js
import { KJUR } from 'jsrsasign';
import { AUTH_LOGOUT, AUTH_REFRESH, AUTH_REQUEST, AUTH_SUCCESS } from '@/store/mutationTypes.js';
import { UNAUTHORIZED_ROUTE_NAME } from '@/settings.js';
import { login, refreshToken } from '@/api/user.js';
import router from '@/router/index.js';

const INITIAL_STATE = {
  accessTokenExpires: 0, // время в unix timestamp
  refreshTokenExpires: 0, // время в unix timestamp
};

const state = INITIAL_STATE;

// функции, которые понадобятся нам для проверки валидности токенов,
// now здесь — unix timestamp текущего момента
const getters = {
  isAccessTokenValid: (state) => (now) => now < state.accessTokenExpires,
  isRefreshTokenValid: (state) => (now) => now < state.refreshTokenExpires,
};

const mutations = {
  [AUTH_SUCCESS]: (state, { accessToken, refreshToken }) => {
    // дехешируем accessToken, достаем дату истечения
    const {
      payloadObj: { exp: accessTokenExpires },
    } = KJUR.jws.JWS.parse(accessToken);

    // дехешируем refreshToken, достаем дату истечения
    const {
      payloadObj: { exp: refreshTokenExpires },
    } = KJUR.jws.JWS.parse(refreshToken);

    // обновляем состояние
    state = Object.assign(state, {
      accessTokenExpires,
      refreshTokenExpires,
    });
  },

  [AUTH_LOGOUT]: (state) => {
    // сбрасываем состояние
    state = Object.assign(state, INITIAL_STATE);

    // перенаправляем на страницу логина
    if (router.currentRoute.name !== UNAUTHORIZED_ROUTE_NAME) {
      router.push({ name: UNAUTHORIZED_ROUTE_NAME });
    }
  },
};

const actions = {
  [AUTH_REQUEST]: async function ({ commit }, { login, password }) {
    const { data, success } = await login({ login, password });

    if (success) {
      commit(AUTH_SUCCESS, data);
    } else {
      commit(AUTH_LOGOUT);
    }

    return { success };
  },

  [AUTH_REFRESH]: async function ({ state, commit }) {
    const { data, success } = await refreshToken();

    if (success) {
      commit(AUTH_SUCCESS, data);
    } else {
      commit(AUTH_LOGOUT);
    }

    // возвращает токен строкой, чтобы обновить заголовок клиента axios
    return data.accessToken;
  },
};

export default {
  state,
  getters,
  mutations,
  actions,
};

 

Начальная конфигурация HTTP клиента:

// @/api/index.js
import axios from 'axios';

const apiClient = axios.create();

export { apiClient };

 

Создаем методы для работы с API пользователя:

// @/api/user.js
import { apiClient } from '@/api/index.js';

// access токен хранится в замыкании модуля webpack
// и не доступен извне
let accessToken = null;
const getAccessToken = () => accessToken;

// флаг skipAuth понадобится нам позднее,
// чтобы интерцептор игнорировал запросы на получение токенов,
// иначе попадем в рекурсию
async function login({ login, password }) {
  const { data } = await apiClient({
    method: 'post',
    url: '/api/user/login',
    data: { login, password },
    skipAuth: true,
  });
  accessToken = data.accessToken;
  return data;
}

// refresh токен хранится в httpOnly cookie, не доступен для js
// и не управляется с фронта
async function refreshToken() {
  const { data } = await apiClient({
    method: 'post',
    url: '/api/user/refreshToken',
    skipAuth: true,
  });
  accessToken = data.accessToken;
  return data;
}

export { login, refreshToken, getAccessToken };

 

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

Как приложению понять, что токен устарел

Для этого мы используем интерцепторы —  функции-перехватчики в Axios. Они вклиниваются в цикл обработки запроса и проверяют актуальность токенов. 

Существуют перехватчики ответа от сервера и самого запроса перед отправкой.

Перехват ответа от сервера. Приложение не следит за сроком жизни access token и получает отказ от сервера на запрос с устаревшим токеном в заголовке. В ответе сервер дает понять, что токен нужно обновить: возвращением статуса 401 или другим способом. Приложение пытается использовать refresh token, чтобы обновить пару токенов. В случае успеха приложение обновляет заголовок с новым access token и отправляет оригинальный запрос заново. В противном случае пользователя перекидывает на форму логина.

Вариант использования интерцепторов при перехвате ответа:

// @/api/index.js
import axios from 'axios';
import store from '@/store/index.js';
import { AUTH_LOGOUT, AUTH_REFRESH } from '@/store/mutationTypes.js';

const apiClient = axios.create();

// обрабатываем запрос после получения
apiClient.interceptors.response.use(
  // сюда попадает все, что валидируется успешным ответом status < 500
  async (response) => {
    const { status } = response;

    if (status === 401) {
      // пытаемся обновить accessToken
      const accessToken = await store.dispatch(AUTH_REFRESH);
      if (accessToken) {
        // если удалось обновить токен, отправляем запрос заново с новым токеном в заголовке
        const _response = await apiClient({
          ...config,
          headers: {
            common: {
              ['Authorization']: `Bearer ${accessToken}`,
            },
          },
        });
        return _response;
      } else {
        // иначе разлогиниваем
        store.commit(AUTH_LOGOUT);
      }
    }

    return response;
  },
  (error) => Promise.reject(error),
);

export { apiClient };

Недостаток этого подхода — лишние запросы. Но логика на фронте упрощается: не нужно следить за временем.

Проверка срока жизни токена перед отправкой запроса. Перед отправкой каждый запрос обрабатывается интерцептором. В нем текущее время сравнивается со временем конца жизни access token. Если его срок пришел, то приложение смотрит на срок жизни refresh token и, если это возможно, обновляет пару. Если нет —  разлогинивает пользователя.

Вариант использования интерцепторов при перехвате ответа:

// @/api/index.js
import axios from 'axios';
import { getAccessToken } from '@/api/user.js';
import store from '@/store/index.js';
import { AUTH_LOGOUT, AUTH_REFRESH } from '@/store/mutationTypes.js';
import { showErrors } from '@/utils/messages.js';

const apiClient = axios.create({
  // ошибки со статусом кода меньше 500 обрабатываем на фронте
  validateStatus: (status) => status < 500,
});

// запросить валидный аксесс токен
async function requestValidAccessToken() {
  // сначала запоминаем текущий accessToken из хранилища
  let { accessToken } = getAccessToken();

  // приводим текущее время к unix timestamp
  const now = Math.floor(Date.now() * 0.001);

  if (!store.getters.isRefreshTokenValid(now)) {
    // Если рефреш токен устарел, разлогиниваем пользователя
    store.commit(AUTH_LOGOUT);
  } else if (!store.getters.isAccessTokenValid(now)) {
    // если accessToken устарел, обновляем его и запоминаем
    accessToken = await store.dispatch(AUTH_REFRESH);
  }

  // возвращаем рабочий accessToken
  return accessToken;
}

// обрабатываем запрос перед отправкой
apiClient.interceptors.request.use(async (config) => {
  // если указан флаг skipAuth, пропускаем запрос дальше как есть
  // этот флаг указан у методов login и refreshToken, они не подкрепляются токенами
  if (config.skipAuth) {
    return config;
  }

  // иначе запрашиваем валидный accessToken
  const accessToken = await requestValidAccessToken();

  // и возвращаем пропатченный конфиг с токенов в заголовке
  return {
    ...config,
    headers: {
      common: {
        ['Authorization']: `Bearer ${accessToken}`,
      },
    },
  };
});

// обрабатываем запрос перед обработкой ответа от сервера
apiClient.interceptors.response.use(
  // сюда попадает все, что валидируется успешным ответом status < 500
  (response) => {
    const {
      data: { errors },
      config: { skipErrors },
      status,
    } = response;

    // если пришла 401, разлогиниваем пользователя
    if (status === 401) {
      store.commit(AUTH_LOGOUT);
    } else if (errors && !skipErrors) {
      // показываем ошибки сервера для фронта, если нет указаний пропустить их вывод
      showErrors(errors);
    }

    return response;
  },
  (error) => Promise.reject(error),
);

export { apiClient };

Реализовать этот подход сложнее, но он поможет не допустить до сервера лишние запросы с устаревшим access token.  

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

Что такое race condition и из-за чего возникает

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

Наши интерцепторы отлично работают до тех пор, пока не отправляется несколько запросов параллельно. 

Допустим, пользователь ушел пить кофе, вернулся через 15 минут и нажал на ссылку в приложении. Access token уже устарел, а при переходе на новую страницу, появились несколько новых компонентов. И этим компонентам нужно подтверждение, что у пользователя действительно есть доступ к ним. И каждый из них, не зная об остальных, отправляет запрос к серверу.
Запросы обрабатывается интерцептором, спешат обновить access token и пропатчить заголовок клиента. Первый запрос получит новый токен и обновит заголовок. А остальные придут на сервер с уже старыми токенами в заголовке, и пользователя выкинет из приложения:

Как синхронизировать запросы через переменную с promise

Решение: при параллельных запросах обновления токена мы отправляем только первый, а для остальных будем ждать его результата. Для этого перед отправкой проверяем, существует ли уже запрос? Если нет — сохраняем запрос, то есть promise, в переменную и ждем ответа. Если да, ждем ответа записанного запроса. Когда запрос выполнится, очищаем переменную для дальнейшего использования.

Конфигурируем интерцептор для разрешения race condition, запоминая запрос в переменную:

import axios from 'axios';
import { getAccessToken } from '@/api/user.js';
import store from '@/store/index.js';
import { AUTH_LOGOUT, AUTH_REFRESH } from '@/store/mutationTypes.js';
import { showErrors } from '@/utils/messages.js';

// переменная для хранения запроса токена
let refreshTokenRequest = null;

const apiClient = axios.create({
  // ошибки со статусом кода меньше 500 обрабатываем на фронте
  validateStatus: (status) => status < 500,
});

// запросить валидный аксесс токен
async function requestValidAccessToken() {
  // сначала запоминаем текущий accessToken из хранилища
  let { accessToken } = getAccessToken();

  // приводим текущее время к unix timestamp
  const now = Math.floor(Date.now() * 0.001);

  if (!store.getters.isRefreshTokenValid(now)) {
    // Если рефреш токен устарел, разлогиниваем пользователя
    store.commit(AUTH_LOGOUT);
  } else if (!store.getters.isAccessTokenValid(now)) {
    // если не было запроса на обновление
    // создаем запрос и запоминаем его в переменную
    // для избежания race condition
    if (refreshTokenRequest === null) {
      refreshTokenRequest = store.dispatch(AUTH_REFRESH);
    }

    // а теперь резолвим этот запрос
    accessToken = await refreshTokenRequest;

    // и очищаем переменную
    refreshTokenRequest = null;
  }

  // возвращаем рабочий accessToken
  return accessToken;
}

// обрабатываем запрос перед отправкой
apiClient.interceptors.request.use(async (config) => {
  // если указан флаг skipAuth, пропускаем запрос дальше как есть
  // этот флаг указан у методов login и refreshToken, они не подкрепляются токенами
  if (config.skipAuth) {
    return config;
  }

  // иначе запрашиваем валидный accessToken
  const accessToken = await requestValidAccessToken();

  // и возвращаем пропатченный конфиг с токенов в заголовке
  return {
    ...config,
    headers: {
      common: {
        ['Authorization']: `Bearer ${accessToken}`,
      },
    },
  };
});

// обрабатываем запрос перед обработкой ответа от сервера
apiClient.interceptors.response.use(
  // сюда попадает все, что валидируется успешным ответом status < 500
  (response) => {
    const {
      data: { errors },
      config: { skipErrors },
      status,
    } = response;

    // если пришла 401, разлогиниваем пользователя
    if (status === 401) {
      store.commit(AUTH_LOGOUT);
    } else if (errors && !skipErrors) {
      // показываем ошибки сервера для фронта, если нет указаний пропустить их вывод
      showErrors(errors);
    }

    return response;
  },
  (error) => Promise.reject(error),
);

export { apiClient };

 

Таким образом, внутри каждого параллельного асинхронного запроса будет резолвиться один и тот же promise, что позволит синхронизировать их и избежать race condition.


Источники и полезные ссылки

Base64 — Википедия

JSON Web Token — Википедия

Зачем нужен Refresh Token, если есть Access Token

Пять простых шагов для понимания JSON Web Tokens (JWT)

Зачем все это? JWT vs Cookie sessions

Фото в заголовке: Steven Lelham on Unsplash