Создание безопасного Flutter приложения используя JWT

При разработке приложений крайне важно обеспечивать безопасность данных пользователей. Один из способов сделать это — использовать аутентификацию JWT (JSON Web Token). В Flutter JWT помогает убедиться, что только правильные пользователи могут получить доступ к определённым частям вашего приложения и безопасно взаимодействовать с вашим бэкендом.

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

Что такое JWT?

JSON Web Tokens — это открытый, промышленный стандарт RFC 7519 метод безопасного представления утверждений между двумя сторонами. JSON Web Token (JWT) похож на цифровое удостоверение личности для пользователей вашего приложения. Он помогает подтвердить, кто они и на что они имеют право. JWT часто используется в приложениях для управления аутентификацией и авторизацией пользователей.

Как работает JWT:

Структура токена:
JWT состоит из трёх частей, разделённых точками (.):

  • Header (Заголовок): Указывает тип токена (JWT) и используемый алгоритм подписи (например, HMAC SHA256).
  • Payload (Полезная нагрузка): Содержит передаваемые данные или утверждения (например, ID пользователя, роль). Это основная информация.
  • Signature (Подпись): Проверяет целостность токена, объединяя закодированный заголовок, полезную нагрузку и секретный ключ.

Мы не будем углубляться в детали работы JWT — наш основной фокус будет на том, как использовать JWT в Flutter для безопасного управления сессиями пользователей и запросами к API.

Понимание аутентификации в JWT

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

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

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

Настройка проекта Flutter:

  1. Убедитесь, что Flutter установлен правильно на вашем устройстве. Для проверки выполните в терминале:
   flutter doctor
  1. Создайте новый проект. Если вы уже работаете над проектом для JWT, перейдите в этот каталог.
   flutter create jwt_auth_flutter # если вы создаёте новый проект
  1. Добавьте следующие зависимости в pubspec.yaml:
   dependencies:
     provider: ^6.1.2
     dio: ^5.5.0+1
     get_it: ^7.7.0
     flutter_secure_storage: ^9.2.2
     jwt_decode: ^0.3.1
  • provider: Эффективно управляет состоянием приложения.
  • dio: Обрабатывает HTTP-запросы и API-вызовы.
  • get_it: Управляет внедрением зависимостей для лёгкого доступа к сервисам.
  • flutter_secure_storage: Безопасно хранит конфиденциальные данные, такие как JWT.
  • jwt_decode: Декодирует JWT для извлечения информации о пользователе и проверки токенов.
  1. Создайте страницы входа и главную страницу. Также можно добавить страницу регистрации в зависимости от доступных конечных точек API.
  2. Создайте директорию services и настройте auth_service.dart и token_service.dart.

Token_Service

Цель: Управляет JWT и связанными операциями.

Обязанности:

  • Безопасно сохраняет и извлекает JWT.
  • Декодирует токены для доступа к данным пользователя.
  • Обновляет или аннулирует токены по мере необходимости.

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

// services/token_service.dart
class TokenService {
  final FlutterSecureStorage _storage = FlutterSecureStorage();

  Future<void> saveToken(String token) async {
    await _storage.write(key: 'jwt', value: token);
  }

  Future<String?> getToken() async {
    return await _storage.read(key: 'jwt');
  }

  Future<void> deleteToken() async {
    await _storage.delete(key: 'jwt');
  }
}

Auth_Service

Цель: Управляет процессами аутентификации пользователя.

Обязанности:

  • Обрабатывает вход и выход пользователя.
  • Проверяет учетные данные пользователя с бэкендом.
  • Управляет сессиями пользователей и состоянием аутентификации.
// services/auth_service.dart
class AuthService {
  Dio _dio = Dio();
  final FlutterSecureStorage _storage = FlutterSecureStorage();

  AuthService(this._dio);

  Future<bool> login(String email, String password) async {
    try {
      final response = await _dio.post(
        '/login',
        data: jsonEncode({'email': email, 'password': password}),
      );

      if (response.statusCode == 200) {
        final token = response.data; // Убедитесь, что это соответствует ответу вашего API
        if (token != null) {
          await _storage.write(key: 'jwt', value: token);
          return true;
        }
      }
    } catch (e) {
      print('Ошибка входа: $e');
    }
    return false;
  }

  Future<bool> signup(String name, String email, String number, String password,
      String confirmPassword) async {
    try {
      final response = await _dio.post(
        '/register',
        data: jsonEncode({
          'fullName': name,
          'email': email,
          'phoneNumber': number,
          'password': password,
          'confirmPassword': confirmPassword,
        }),
      );

      if (response.statusCode == 200) {
        final token = response.data; // Убедитесь, что это соответствует ответу вашего API
        if (token != null) {
          await _storage.write(key: 'jwt', value: token);
          return true;
        }
      }
    } catch (e) {
      print('Ошибка регистрации: $e');
    }
    return false;
  }

  Future<String?> getToken() async {
    return await _storage.read(key: 'jwt');
  }

  Future<void> logout() async {
    await _storage.delete(key: 'jwt');
  }

  Map<String, dynamic> parseJwt(String token) {
    final parts = token.split('.');
    if (parts.length != 3) {
      throw Exception('Неверный токен');
    }
    final payload =
        utf8.decode(base64Url.decode(base64Url.normalize(parts[1])));
    return json.decode(payload);
  }
}

Регистрация этих сервисов с использованием GetIt в приложении:

// di/service_locator.dart
final GetIt locator = GetIt.instance;

void setupLocator() {
  locator.registerLazySingleton<Dio>(() {
    final dio = Dio(BaseOptions(baseUrl: API_URL)); 
    // API_URL может быть URL вашего сервера, например: 'http://localhost:7087/api/'
    dio.interceptors.add(InterceptorsWrapper(
      onRequest: (options, handler) async {
        // Добавить JWT токен в заголовки запроса
        final token = await locator<TokenService>().getToken();
        if (token != null) {
          options.headers['Authorization'] = 'Bearer $token';
        }
        return handler.next(options);
      },
    ));
    return dio;
  });

  locator.registerLazySingleton<AuthService>(() => AuthService(locator<Dio>()));
  locator.registerLazySingleton<TokenService>(() => TokenService());
  locator.registerLazySingleton<FlutterSecureStorage>(() => FlutterSecureStorage());
}

Настройка auth_provider.dart

// provider/auth_provider.dart
class AuthProvider with ChangeNotifier {
  final AuthService _authService;
  bool _isAuthenticated = false;

  AuthProvider(this._authService);

  bool get isAuthenticated => _isAuthenticated;

  Future<bool> login(String email, String password) async {
    var success = await _authService.login(email, password);
    print(success);
    if (success) {
      _isAuthenticated = true;
      await _updateUser();
      notifyListeners();
    }
    return success;
  }

  Future<bool> signup(String name, String email, String number, String password, String confirmPassword) async {
    bool success = await _authService.signup(name, email, number, password, confirmPassword);
    print(success);
    if (success) {
      _isAuthenticated = true;
      await _updateUser();
      notifyListeners();
    }
    return success;
  }

  Future<void> logout() async {
    await _authService.logout();
    _isAuthenticated = false;
    _user = null;
    notifyListeners();
  }

  // Метод _updateUser() должен быть реализован для обновления данных пользователя
}

Создание представлений для входа и регистрации:

Простой код представления для входа:

// lib/views/login_view.dart
class LoginView extends StatelessWidget {
  const LoginView({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final loginViewModel = Provider.of<LoginViewModel>(context);

    return Scaffold(
      appBar: AppBar(
        title: const Text('Вход'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            TextField(
              controller: loginViewModel.emailController,
              decoration: const InputDecoration(labelText: 'Электронная почта'),
            ),
            TextField(
              controller: loginViewModel.passwordController,
              obscureText: true,
              decoration: const InputDecoration(labelText: 'Пароль'),
            ),
            const SizedBox(height: 20),
            loginViewModel.isLoading
                ? const Center(child: CircularProgressIndicator())
                : ElevatedButton(
                    onPressed: () {
                      loginViewModel.login(context);
                    },
                    child: const Text('Войти'),
                  ),
          ],
        ),
      ),
    );
  }
}

ViewModel для LoginView:

// lib/viewmodels/Login_viewmodel.dart
class LoginViewModel extends ChangeNotifier {
  final TextEditingController emailController = TextEditingController();
  final TextEditingController passwordController = TextEditingController();

  bool _isLoading = false;

  bool get isLoading => _isLoading;

  void setLoading(bool value) {
    _isLoading = value;
    notifyListeners();
  }

  Future<void> login(BuildContext context) async {
    final authProvider = Provider.of<AuthProvider>(context, listen: false);

    final email = emailController.text.trim();
    final password = passwordController.text.trim();

    if (email.isEmpty || password.isEmpty) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Пожалуйста, заполните все поля')),
      );
      return;
    }

    setLoading(true);

    final success = await authProvider.login(email, password);
    setLoading(false);

    if (success) {
      Navigator.pushReplacementNamed(context, Routename.home);
    } else {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Вход не удался! Пожалуйста, попробуйте снова.')),
      );
    }
  }

  @override
  void dispose() {
    emailController.dispose();
    passwordController.dispose();
    super.dispose();
  }
}

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

Представление и ViewModel для регистрации:

// lib/viewmodels/signup_viewmodel.dart
class SignupPasswordViewModel extends ChangeNotifier {
  final TextEditingController passwordController = TextEditingController();
  final TextEditingController confirmPasswordController = TextEditingController();

  String? name;
  String? email;
  String? phone;

  bool _isLoading = false;

  bool get isLoading => _isLoading;

  void setLoading(bool value) {
    _isLoading = value;
    notifyListeners();
  }

  void updateName(String? name) {
    this.name = name;
    notifyListeners();
  }

  void updateEmail(String? email) {
    this.email = email;
    notifyListeners();
  }

  void updatePhone(String? phone) {
    this.phone = phone;
    notifyListeners();
  }

  Future<void> signup(BuildContext context) async {
    if (_validatePasswords()) {
      setLoading(true);

      final authProvider = Provider.of<AuthProvider>(context, listen: false);
      final success = await authProvider.signup(
        name!,
        email!,
        phone!,
        passwordController.text.trim(),
        confirmPasswordController.text.trim(),
      );

      setLoading(false);
      print(success);
      if (success) {
        print('Регистрация успешна');
        Navigator.pushNamed(context, Routename.home); // Вы можете настроить маршрутизацию по своему методу
      } else {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('Регистрация не удалась! Пожалуйста, попробуйте снова.')),
        );
      }
    } else {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Пароли не совпадают или недействительны')),
      );
    }
  }

  bool _validatePasswords() {
    final password = passwordController.text;
    final confirmPassword = confirmPasswordController.text;

    return password == confirmPassword &&
        password.isNotEmpty &&
        confirmPassword.isNotEmpty;
  }

  @override
  void dispose() {
    passwordController.dispose();
    confirmPasswordController.dispose();
    super.dispose();
  }
}
// lib/views/signup_view.dart
class SignupView extends StatelessWidget {
  const SignupView({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final signupViewModel = Provider.of<SignupPasswordViewModel>(context);

    return Scaffold(
      appBar: AppBar(
        title: const Text('Регистрация'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            TextField(
              onChanged: signupViewModel.updateName,
              decoration: const InputDecoration(labelText: 'Имя'),
            ),
            TextField(
              onChanged: signupViewModel.updateEmail,
              decoration: const InputDecoration(labelText: 'Электронная почта'),
            ),
            TextField(
              onChanged: signupViewModel.updatePhone,
              decoration: const InputDecoration(labelText: 'Телефон'),
            ),
            TextField(
              controller: signupViewModel.passwordController,
              obscureText: true,
              decoration: const InputDecoration(labelText: 'Пароль'),
            ),
            TextField(
              controller: signupViewModel.confirmPasswordController,
              obscureText: true,
              decoration: const InputDecoration(labelText: 'Подтвердите пароль'),
            ),
            const SizedBox(height: 20),
            signupViewModel.isLoading
                ? const Center(child: CircularProgressIndicator())
                : ElevatedButton(
                    onPressed: () {
                      signupViewModel.signup(context);
                    },
                    child: const Text('Зарегистрироваться'),
                  ),
          ],
        ),
      ),
    );
  }
}

И всё! Мы рассмотрели основы реализации аутентификации с использованием JWT в Flutter.

Но следующий вопрос: как использовать JWT токен в качестве ключа авторизации в заголовке API-запроса?

Ответ прост:
Мы создали token_service.dart, который выполняет операции CRUD с токеном.
Мы можем получить токен, используя метод locator<TokenService>().getToken();.

Например: вот простой API-вызов, который запрашивает данные профиля пользователя.

Future<UserModel?> loadUserProfile() async {
  try {
    final token = await locator<TokenService>().getToken();
    if (token == null) return null;

    final response = await _dio.get(
      '/profile',
      options: Options(
        headers: {'Authorization': 'Bearer $token'},
      ),
    );

    if (response.statusCode == 200) {
      final user = UserModel.fromJson(response.data);
      return user;
    }
  } catch (e) {
    print('Ошибка загрузки профиля пользователя: $e');
  }
  return null;
}
// Убедитесь, что у вас настроены модели для всех вышеупомянутых сервисов

Я постарался как можно лучше объяснить и продемонстрировать, «как использовать JWT токен для аутентификации и выполнения API-вызовов на бэкенд.»
Не стесняйтесь экспериментировать с кодом и настраивать его под нужды вашего проекта. Понимая, как работает JWT и как его реализовать, вы добавляете важный уровень безопасности в свои мобильные приложения.


Источник: https://medium.com/@areesh-ali/building-a-secure-flutter-app-with-jwt-and-apis-e22ade2b2d5f

Leave a Comment