
Привет, Хабр! Я — Роза, 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.
Header — заголовок, который хранит тип токена (JWT) и алгоритм подписи (например, HMAC SHA256).
Payload — данные о пользователе и токене. Стандартные поля: iss (issuer) — кто выдал токен, sub (subject) — идентификатор пользователя, aud (audience) — для кого предназначен токен, exp (expiration) — время, в течение которого токен считается валидным, iat (issued at) — время создания токена, jti (JWT ID) — уникальный идентификатор токена.
Signature — подпись, гарантия целостности.
Важный момент: данные в Payload видны всем. JWT подписывается, а не шифруется. Веб-токен нельзя подделать без секретного ключа, но содержимое можно прочитать. Поэтому не стоит хранить там пароли и чувствительные данные.
При выходе из аккаунта или смене пароля токены нужно отзывать. Для этого используют черный список (Blacklist). При проверке сервер сначала смотрит, не попал ли токен туда, и только потом валидирует его. Если токен есть в черном списке, возвращается ошибка 401. Обычно в Blacklist заносят не Access (они и так быстро перестают действовать), а именно Refresh-токены при логауте, чтобы злоумышленник не мог бесконечно генерировать новые сессии.
Как проходит аутентификация с JWT
Когда говорят об аутентификации с помощью JWT, то имеют в виду, как приложения подтверждают личность пользователя.
Устроено это таким образом:
Пользователь вводит логин и пароль.
Сервер проверяет данные и, если они верны, генерирует Access и Refresh токены.
Клиент сохраняет токены (например, в Secure Storage).
При каждом запросе к защищенному API клиент отправляет Access token в заголовке Authorization: Bearer <token>.
Сервер проверяет подпись и срок действия токена.
Если 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? Расскажите в комментариях!
