Hola, Amigos! На связи Павел Гершевич, Mobile Team Lead агентства продуктовой разработки Amiga и соавтор книги “Основы Flutter”. В каждом приложении мы авторизуем пользователей, но не все встраивают механизмы обновления токенов.

Из статьи вы узнаете:

  • Из чего состоит JWT-токен?

  • Зачем нужны Interceptor’ы в Dio и  чем отличается QueryInterceptor?

  • Какие есть способы обновления токенов?

Немного теории

Чтобы разобраться, как и зачем обновлять токены авторизации, для начала вспомним базу: что это вообще такое и как сервер понимает, кто к нему стучится?

Как сервер авторизует запросы

Разберемся с тем, как сервер обрабатывает информацию о пользователе и его авторизации.

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

Authorization: <auth-scheme> <auth-parameters>

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

Во-вторых, чаще всего проверка выносится в middleware, который добавляется ко всем методам. Могут быть методы и без защиты авторизацией. В случаях, когда данные авторизации нужны, но отсутствуют, или невалидны, сервер выдаст ошибку. Чаще всего используется 401 Unauthorized. Если не хватает прав, то может быть ошибка 403 Forbidden.

Давайте посмотрим на то, как обычно работает данный middleware:

Обработка обновления токена на сервере
Обработка обновления токена на сервере

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

  • Пользователь по данным credentials не найден;

  • Токен или сессия истекли;

  • Логин или пароль неверны.

Схемы авторизации запросов

Как мы уже разобрались, для того, чтобы сервер смог авторизовать запрос, мы должны передать в него заголовок Authorization. Теперь посмотрим на его схемы. Их достаточно много, но чаще всего используются две - Basic и Bearer.

Basic - когда мы передаем логин и пароль пользователя при каждом запросе. Делается это через Base64 строку, в которой закодированы наши данные. Заголовок для запроса будет примерно такой: Authorization: Basic bG9naW46cGFzc3dvcmQ=. У такого способа есть проблемы с безопасностью, так как он легко поддается декодированию.

Bearer - осуществляется при помощи токенов. Пример заголовка для такого способа Authorization: Bearer <Token>. У токена может быть срок годности - поэтому его необходимо обновлять. Также токены более защищены, чем Base64 строка, из-за этого такая схема более популярна при разработке API.

Что такое JWT-токен

Чаще всего при работе с авторизацией через токен мы сталкиваемся с JWT (Json Web Token). Они состоят из трех частей:

  • Header, который указывает на алгоритм шифрования и тип токена;

  • Payload, в который заносится информация от сервера о пользователе, длительности жизни токена и все, что серверу будет еще необходимо в дальнейшем;

  • Secret - зашифрованные данные при помощи ключа, который знает только ваш сервер.

Все это переводится в Base64 и отдается нам строкой вида {Header}.{Payload}.{Secret}. Такой токен мы можем сохранить на устройстве и отправлять в заголовках к запросам, пока мы не получим ошибку 401 Unauthorized. В таком случае мы можем назвать токен “протухшим”.

Обновление JWT

При протухании токена мы можем попросить сервер выдать нам новый на основании старого. Для этого нам необходим специальный метод API, на который не действует middleware, рассмотренный нами ранее.

Обновление JWT на сервере
Обновление JWT на сервере

Системы из нескольких токенов

Для большей безопасности применяется система из 2 токенов - Access Token и Refresh Token. Первый для запросов, его не сохраняем, так как у него обычно очень ограниченный срок службы - несколько минут. Второй служит для обновления и получения первого токена, обычно тоже имеет свой срок жизни, после которого пользователя нужно будет разлогинить.

Что делать, когда протух токен

Давайте посмотрим на 3 варианта действий, если у вас протух токен:

Вариант 1. Разлогиниваем пользователя

Данный вариант максимально прост. Мы получаем от сервера ошибку 401 Unauthorized и разлогиниваем пользователя. Но такое подойдет не всем. Особенно его не стоит использовать в системе из двух токенов.

Вариант 1. Разлогиниваем пользователя
Вариант 1. Разлогиниваем пользователя

Плюсы:

  • Простота реализации.

Минусы:

  • Пользователь должен авторизоваться после того, как токен протух;

  • Сервер может отозвать токен в любой момент;

  • Не подходит для систем из двух токенов.

Вариант 2. Обновляем токен после протухания

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

Вариант 2. Обновляем после протухания
Вариант 2. Обновляем после протухания

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

Поэтому правильно будет написать middleware, который делал бы всего один запрос вместо нескольких и после этого одновременно перезапрашивал данные. При использовании dio нам нужно будет написать Interceptor.

Interceptor в Dio

Создать собственный Interceptor в Dio можно двумя способами - наследовавшись от класса Interceptor или создав объект InterceptorWrapper. Мы советуем идти первым способом, так как это позволит нам иметь переменные, а они нам понадобятся в дальнейшем.

Для Interceptor мы можем переписать 3 метода:

  • onRequest - вызывается при формировании запроса, до его отправки;

  • onResponse - вызывается при ответе, если не получена ошибка;

  • onError - вызывается при ошибке.

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

class TokenInterceptor extends Interceptor {
  final TokenStorage _storage;

  TokenInterceptor(this._storage);

  @override
  Future<void> onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
    final token = await _storage.getToken();
    if (token != null) {
      options.headers[‘Authorization’] = ‘Bearer $token’;
    }
    handler.next(options);
  }
}

Далее подключим наш интерсептор к Dio:

final dio = Dio()..interceptors = [TokenInterceptor(TokenStorage())];

Обновление при ошибке

Для реализации необходим метод API, который будет возвращать новый токен. Следующий шаг - подготовить переопределение onError для дальнейшей обработки.

@override
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
  if (err.response.code == 401) {
    ... // Тут будет происходить дальнейшая работа
  } else {
    handler.reject(err);
  }
}

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

Future<String?> _updateToken() async {
  final oldToken = await _storage.getToken();
  try {
    final dio = Dio(); // Тут можно донастроить dio под вас
    final response = await dio.get(
      ‘/auth/refresh/’, 
      headers: {‘Authorization’: ‘Bearer $oldToken’}
    );
    // Получаем токен
    final newToken = (response.body as Map<String, dynamic)[‘token’];
    await _storage.setNewToken(newToken);
    return newToken;
  } catch (e) {
    // При любой ошибке разлогиниваем пользователя
    await _storage.clearToken();
    return null;
  }
}

Теперь нам нужно создать очередь из запросов, которые вернули ошибку 401 Unauthorized, чтобы после обновления токена отправить их заново. Для этого нам понадобится Completer. Он поможет отслеживать момент завершения обновления токена на тех запросах, которые упали позже. А также нам нужен будет флаг, что прямо сейчас мы обновляем, по умолчанию он будет false.

Completer<void>? _refreshCompleter;
bool _isRefreshing = false;
Вариант 2. Обновляем после протухания (полная схема)
Вариант 2. Обновляем после протухания (полная схема)

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

if (!_isRefreshing) {
  _refreshCompleter = Completer<void>();
  _isRefreshing = true;
  await _updateToken();
  _refreshCompleter?.complete();
  _isRefreshing = false;
  _refreshCompleter = null;
} else if (_refreshCompleter != null) {
  await _refreshCompleter?.future;
}

try {
  final newToken = await _storage.getToken();
  if (newToken != null) {
    options.headers[‘Authorization’] = ‘Bearer $newToken’;
  }
  final response = await Dio().fetch(options.requestOptions);
  handler.resolve(response);
} catch (e) {
  handler.reject(e);
}

Плюсы подхода:

  • Подходит для обновления системы из 2 токенов;

  • Достаточно простая реализация, без дополнительных библиотек.

Минусы:

  • Нагрузка на сервер. Если упадет много запросов, то после обновления токенов они полетят одномоментно. Такое может случиться, если пользователь давно не заходил в приложение;

  • Увеличивается время получения ответа, так как нам нужно сначала дождаться ошибки, потом обновить токен и еще раз отправить запрос.

Вариант 3. Обновляем перед отправкой запроса

Если вас не устраивает то, как работает второй вариант или вы задаетесь вопросом “а можно ли лучше и эффективнее?”, то вам следует рассмотреть третий вариант.

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

Dio QueryInterceptor

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

Interceptor vs QueryInterceptor
Interceptor vs QueryInterceptor

Единственное отличие, что запросы через него проходят один за одним в жесткой очереди. В этом его главный плюс и он же минус: если из него мы отправим еще один запрос через тот же Dio, то ответа мы никогда не дождемся. Обработка застрянет на первом запросе в очереди, а новый будет в ней последним. Именно это стало причиной использования отдельного Dio для обновления токена.

Давайте переделаем наш Interceptor на QueryInterceptor:

class TokenInterceptor extends QueryInterceptor {
  final TokenStorage _storage;

  TokenInterceptor(this._storage);

  @override
  Future<void> onRequest(
    RequestOptions options, RequestInterceptorHandler handler
  ) async {
    final token = await _storage.getToken();
    if (token != null) {
      // Тут будем производить манипуляции по обновлению
    }
    handler.next(options);
  }
}

Получаем время истечения токена

Вариант 3. Обновляем до запроса
Вариант 3. Обновляем до запроса

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

Потом достать время из Payload, зная название переменной. В нашем случае это был exp в виде timestamp.

final tokenPayload = JWT.decode(token);
final expirationDate = DateTime.fromMillisecondsSinceEpoch(token.payload[‘exp’]);

Проверяем токен и при необходимости обновляем

Для обновления мы используем метод _updateToken из прошлого варианта. В начале мы получаем время истечения токена, потом сверяемся с текущим, добавив к нему несколько секунд (в нашем варианте было 5), чтобы не упало в 401 Unauthorized при долгих обработках перед отправкой, при необходимости обновляем, а уже в конце подставляем токен.

final expirationDate = _storage.getExpirationDate(token);
if (expirationDate.isBefore(DateTime.now().add(Duration(seconds: 5)))) {
  await _updateToken();
  final newToken = await _storage.getToken();
  options.headers[‘Authorization’] = ‘Bearer $newToken’;
} else {
  options.headers[‘Authorization’] = ‘Bearer $token’;
}

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

if (newToken != null) {
  options.headers[‘Authorization’] = ‘Bearer $newToken’;
} else {
  handler.reject(DioException(
      requestOptions: options,
      message: ‘Token expired and was not updated’,
    )
  );
  return;
}

Плюсы:

  • Подходит для обновления системы из 2 токенов;

  • Отправляет минимальное количество запросов к API.

Минусы:

  • Обязательно знать срок истечения токена;

  • Увеличивает время запроса на время ожидания обновления токена. Это меньше, чем во втором варианте, так как мы не ждем ошибку 401 Unauthorized;

  • Возможно использование дополнительной библиотеки.

Заключение

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

Если есть вопросы — буду ждать вас в комментариях. А еще подписывайтесь на телеграм-канал про Flutter-разработку — я часто там пишу и делюсь полезными инсайтами и новостями из мира мобильной разработки.