Привет, Хабр! Я — Роза, Flutter Dev Friflex. Сегодня я расскажу о веб-токенах JWT: как с их помощью безопасно передавать данные и реализовать авторизацию во Flutter. Разберем, чем JWT отличаются от классической схемы с сессиями, как работают Access- и Refresh-токены, зачем нужен Blacklist и как все это собрать в рабочее решение.

Что такое JWT

Представьте, что у вас есть данные, которые нужно защитить и передать через API. Чтобы сделать это безопасно, можно использовать JWT (JSON Web Token).

Веб-токены JSON (JWT) — это компактный способ передачи информации между сторонами в виде JSON-объекта. JWT подписывается секретным ключом (HMAC) или парой ключей (RSA/ECDSA), что позволяет убедиться в целостности данных.

У веб-токенов ограниченный срок жизни. Например, Access Token может действовать 15 минут. Даже если его украдут, он быстро станет бесполезным.

В отличие от классической схемы с сессиями, серверу не нужно хранить состояние каждой пользовательской сессии в базе данных: он может проверить подпись токена локально. Но если используется Blacklist или хранение Refresh Token, сервер все же обращается к базе или Redis.

Есть два вида веб-токенов:

  • Access token — токен для доступа к API. Он живет примерно 15 минут, и после этого сервер возвращает ошибку 401. По умолчанию токен многоразовый до истечения срока действия.

  • Refresh token — токен для обновления пары токенов. Он живет дольше — несколько дней. Используется на эндпоинте ~/auth/refresh, когда истекает срок Access токена.

Из чего состоят JWT, и как они защищают данные

Токен состоит из трех частей, разделенных точкой: xxxxx.yyyyy.zzzzz.

  1. Header — заголовок, который хранит тип токена (JWT) и алгоритм подписи (например, HMAC SHA256).

  2. Payload — данные о пользователе и токене. Стандартные поля: iss (issuer) — кто выдал токен, sub (subject) — идентификатор пользователя, aud (audience) — для кого предназначен токен, exp (expiration) — время, в течение которого токен считается валидным, iat (issued at) — время создания токена, jti (JWT ID) — уникальный идентификатор токена. 

  3. Signature — подпись, гарантия целостности.

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

При выходе из аккаунта или смене пароля токены нужно отзывать. Для этого используют черный список (Blacklist). При проверке сервер сначала смотрит, не попал ли токен туда, и только потом валидирует его. Если токен есть в черном списке, возвращается ошибка 401. Обычно в Blacklist заносят не Access (они и так быстро перестают действовать), а именно Refresh-токены при логауте, чтобы злоумышленник не мог бесконечно генерировать новые сессии.

Как проходит аутентификация с JWT

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

Устроено это таким образом:

  1. Пользователь вводит логин и пароль.

  2. Сервер проверяет данные и, если они верны, генерирует Access и Refresh токены.

  3. Клиент сохраняет токены (например, в Secure Storage).

  4. При каждом запросе к защищенному API клиент отправляет Access token в заголовке Authorization: Bearer <token>.

  5. Сервер проверяет подпись и срок действия токена.

  6. Если Access token истек, клиент использует Refresh token для получения новой пары токенов.

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

Как реализовать JWT-авторизацию во Flutter (Dart)

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

Создание и подпись токена

Эта логика находится на сервере, который генерирует токен после успешной аутентификации пользователя.

Код генерации и верификации токена (JWT.sign, JWT.verify) должен выполняться только на стороне сервера. Не стоит хранить SecretKey внутри Flutter-приложения, так как его можно извлечь с помощью реверс-инжиниринга.

Напомню, JWT состоит из трех частей: Header, Payload и Signature. В коде это можно описать так:

import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import 'package:uuid/uuid.dart';
// Создаем полезную нагрузку (Payload) для токена
final payload = {
  // jti — уникальный идентификатор токена, необходимый для Blacklist
  'jti': const Uuid().v4(), 
  'userId': 123,
  'role': 'user',
};
// Создаем экземпляр JWT
final jwt = JWT(payload);
// Подписываем токен с помощью секретного ключа
// Секретный ключ должен храниться только на сервере и никогда не передаваться на клиент
// expiresIn задает срок действия токена в секундах
final token = jwt.sign(
  SecretKey('ВАШ_СЕКРЕТНЫЙ_КЛЮЧ'), 
  expiresIn: const Duration(minutes: 15),
);

Библиотека dart_jsonwebtoken автоматически добавляет Header и Signature, а также поле Exp (срок действия) на основе ExpiresIn.

Также возможно создание ключей по другим алгоритмам (RSA SHA-256, ECDSA P-256, ECDSA secp256k и другим). Для этого необходимо передать key с нужным алгоритмом в sign.

Например:

// Читаем приватный ключ из файла
final pem = File('./rsa_private.pem').readAsStringSync();
final key = RSAPrivateKey(pem);
// Подписываем токен с указанием алгоритма
final token = jwt.sign(key, algorithm: JWTAlgorithm.RS256);

Проверка токена (верификация)

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

try {
  // Верифицируем токен с помощью секретного ключа
  final jwt = JWT.verify(token, SecretKey('SECRET_KEY'));
  // Если верификация успешна, можно получить полезную нагрузку
  print('Payload: ${jwt.payload}');
} on JWTExpiredException {
  // Срок действия токена истек
  print('Token expired');
} on JWTInvalidException {
  // Токен недействителен (изменен, некорректная подпись)
  print('Token is invalid');
}

dart_jsonwebtoken автоматически проверяет срок действия токена и выбрасывает исключение, если он истек.

Отзыв токена: Refresh Token и Blacklist

Основная проблема JWT в том, что даже если пользователь вышел из системы, токен остается валидным до истечения срока действия. Чтобы решить эту проблему, используют короткоживущие Access Token и долгоживущие Refresh Token, а также черный список. 

Реализовать Blacklist вы можете примерно таким образом:

  • Добавляете таблицу blacklisted_tokens для хранения jti (JWT ID). 

  • При генерации токена задаете уникальный ID. Например, с помощью пакета uuid:

import 'package:uuid/uuid.dart';
final uuid = Uuid();
final payload = {
  …,
  'jti': uuid.v4(), // Уникальный ID токена
};
  • При проверке учитываете Blacklist:

bool valid(String secretKey, {Set<String> blackList = const {}}) {
final jwt = JWT.verify(token, SecretKey(secretKey));
  final jti = jwt.payload['jti'] as String?;
  final notBlacklisted = !(jti != null && blackList.contains(jti));
  return notBlacklisted;
}

Хранение токенов

На клиенте Blacklist хранить не нужно — это задача сервера. Клиент просто перестает использовать токены при выходе.

Для хранения токена используем flutter_secure_storage.

import 'package:flutter_secure_storage/flutter_secure_storage.dart';
final storage = FlutterSecureStorage();
// Сохранение токенов
Future<void> saveTokens(String accessToken, String refreshToken) async {
  await storage.write(key: 'access_token', value: accessToken);
  await storage.write(key: 'refresh_token', value: refreshToken);
}
// Получение токенов
Future<String?> getAccessToken() async {
  return await storage.read(key: 'access_token');
}
Future<String?> getRefreshToken() async {
  return await storage.read(key: 'refresh_token');
}
// Удаление токенов при выходе
Future<void> deleteTokens() async {
  await storage.delete(key: 'access_token');
  await storage.delete(key: 'refresh_token');
}

Отправка токена на сервер

Используем HTTP-запросы и передаем токен в заголовке Authorization.

Пример кода на клиенте (c Dio):

import 'package:dio/dio.dart';
final dio = Dio();
Future<void> sendToken(String token) async {
  final response = await dio.get(
    'https://example.com/api',
    options: Options(
      headers: {'Authorization': 'Bearer $token'},
    ),
  );
  print(response.data);
}

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

Это основа для реализации JWT-авторизации во Flutter. Конечно, здесь не все тонкости, но это база для старта. А как вы реализуете JWT-авторизацию во Flutter? Расскажите в комментариях!