Cet article présente la notion de communication sécurisée basée sur des jetons entre l’application Flutter et le serveur Web. Il décrit un protocole générique et un flux basé sur l’API Web mais sans se focaliser sur des standards tels que le protocole OAuth2.

Difficulté: Intermédiaire

Contexte

Quand j’ai eu à travailler dans le domaine de la communication sécurisée entre un client et un serveur, j’ai dû développer une solution qui fonctionnait avec de vieux protocoles comme X25 ou RS-232 en langage C et sans utiliser de librairie.

L’une des exigences était de s’assurer que les paquets interceptés ne pourraient pas être réutilisés ou rejoués à un stade ultérieur. J’ai ensuite utilisé le protocole Kerberos qui implique la notion de jetons et de cryptage en utilisant DES.

Plus tard, lorsque j’ai conçu des sites Web qui traitaient également de la communication sécurisée, j’ai utilisé JWT.

Lorsque j’ai commencé à écrire ma première application Smartphone, je devais trouver une solution pour informer mon serveur de l’identité de l’émetteur des requètes. J’ai donc essayé et suis arrivé à la solution suivante.

Raisonnement

À l’exception des applications Smartphone autonomes, la plupart des applications doivent communiquer avec un serveur pour interagir. En outre, dans la plupart des cas, le serveur doit connaître et valider l’identité de l’application et / ou de l’utilisateur avant de traiter les demandes.

Contrairement aux sites Web, la notion de session Web ne doit pas être prise en compte. Par conséquent, comment pouvons-nous faire savoir au serveur si une requête qu’il reçoit est valide ou non, ainsi que l’identité du demandeur ?

Pour ceux qui pourraient être intéressés par le protocole standard d’autorisation, vous pouvez vous référer à OAuth 2.0 pour plus d’explications.

Cet article ne concerne pas OAuth 2.0 mais décrit un protocole privé basé sur la notion de jeton (=token) pour expliquer le principe générique de ce genre de communication sécurisée.

Qu’est-ce qu’un Token (Jeton) ?

En termes simples, un jeton est quelque chose qui, entre autre:

  • est échangé entre le client (= demandeur) et le serveur à chaque requête;
  • contient des éléments d’information qui:
    • assurent son intégrité;
    • permettent au serveur de connaître et de valider l’identité du demandeur;
    • permettent au serveur de savoir et de contrôler quand le jeton expire;
    • permettent au serveur de contrôler le flux des opérations grâce à un contrôle de la validité de la requête;
  • doit être sécurisé contre les tentatives de modification ou de réutilisation dans un schéma inapproprié

Identité du demandeur

Comme l’identité du demandeur est une donnée très sensible, quelle information pourrions-nous utiliser ?

L’identité de l’utilisateur est quelque chose qui peut être piratée assez facilement. Par conséquent, nous devons trouver quelque chose de plus fort et, heureusement, chaque appareil mobile a un identifiant unique.

Cette information sera utilisée pour identifier le demandeur en termes d’appareil (à partir de quel appareil la demande est-elle émise?).

Deux informations supplémentaires pourraient également être utilisées pour renforcer / compléter l’identité du demandeur:

  • le nom ou l’identifiant de l’application;
  • l’utilisateur (login, identifiant, email …).

Sécurisation de la communication

La communication entre Phone App et le serveur utilise les requêtes HTTP (GET, POST, PUT ou DELETE).

Afin de sécuriser davantage la communication entre l’application téléphonique et le serveur, il est recommandé de:

  • utiliser SSL pour crypter la communication et donc utiliser les requêtes HTTP S;
  • passer le jeton dans l’en-tête (=header) des requêtes HTTPS;
  • passez également systématiquement l’identifiant de l’application et l’identifiant unique du périphérique, également dans l’en-tête de chaque demande HTTPS.

Qui génère les Jetons ?

Par définition, seul le Serveur est autorisé à générer les jetons.

Types de jetons

Dans la plupart des cas, il suffit de ne considérer que 2 types de jetons:

Handshake

Lors de la communication de l’application Smartphone vers le serveur, aucun jeton n’étant disponible, la toute première requête à envoyer au serveur est une demande de handshake, demandant au serveur de générer un jeton initial, avec une durée de vie très courte. Ce jeton devra obligatoirement être réutilisé lors de la prochaine communication entre l’application et le serveur.

Comme ce jeton initial ne contient aucune donnée sensible et a une durée de vie très limitée (quelques millisecondes voire seconds, tout au plus), nous ne devons pas nécessairement rendre sa transmission (Serveur -> Application) trop sécurisée. Par conséquent, une requête HTTPS GET normale est acceptable.

handshake token

Une telle requête pourrait ressembler à ceci:

var response = await http.get('https://www.myserver.com/api/mobile/handshake',
				headers: {
				  'X-DEVICE-ID': 'my_device_id',
				  'X-TOKEN': '',
				  'X-APP-ID': 'my_application_id'
				});
				
if (response.statusCode == 200) {
  String token = response.body;
}

Un autre cas peut également exister: l’application conserve le jeton d’une instance / session à une autre (cas où vous voulez que l’utilisateur soit automatiquement (ré-)authentifié lorsque l’application est relancée).

Dans ce cas, plutôt que de soumettre un jeton vide lors de la demande d’établissement de la communication, nous envoyons le dernier jeton connu. Le serveur valide le jeton, procède potentiellement à l’authentification si le jeton semble être valide, mais génère systématiquement un nouveau jeton, comme réponse.

Jeton de Communication

C’est le deuxième type de jeton à utiliser pour n’importe quel type de requête.

Il est généré par le serveur lorsque les cas suivants se produisent:

  • après une demande d’authentification
  • lorsque le jeton a expiré ou va expirer très bientôt.

Comment générer un jeton ?

Comme indiqué précédemment, un jeton peut contenir de nombreuses informations différentes, être sécurisé, avoir une date d’expiration et permettre au serveur de procéder à la validation de toute demande.

Notion d’état d’un jeton

Dans le protocole de communication, nous pourrions également inclure la notion d’état de jeton. Ces informations pourraient être utilisées du côté du serveur, après le processus de validation de jeton, comme moyen de contrôle de flux de second niveau.

Pour illustrer cette notion, nous pourrions imaginer la séquence de requêtes habituelle suivante, avec le jeton état correspondant, renvoyé par le Serveur:

  • handshaking -> “requires_authentication
  • authentification -> “jeton
  • d’autres types de requêtes -> aucun jeton n’est retourné (cependant si la durée de vie du jeton est courte, nous pourrions envisager de générer de nouveaux jetons au milieu des requêtes “usuelles” Dans ce cas, un jeton “pourrait être retourné)

Par conséquent, lorsque le serveur reçoit une demande nécessitant une authentification, si le jeton n’est pas de l’état “token”, la demande peut être rejetée. Par extension, si le serveur reçoit une requête “authentification” mais que le jeton n’est pas de l’état “requires_authentication”, il peut rejeter la requête.

La façon de construire un jeton

La façon la plus simple de créer un jeton est de concaténer une série d’informations textuelles, d’utiliser un séparateur entre chacune d’entre elles, puis de le chiffrer à l’aide d’un algorithme de chiffrement unidirectionnel et de transformer le résultat en chaîne Base64.

Exemple:

{device_id}~{application_id}~{user_id}~{expirationTimestamp}~{type}~{sand}

pourrait être la chaîne concaténée, où:

  • device_id est l’identité unique du dispositif mobile, telle qu’elle est envoyée par l’application téléphonique;
  • application_id est l’identité unique de l’application;
  • user_id après authentification, est l’identité de l’utilisateur, vide avant l’authentification;
  • expirationTimestamp est la date et l’heure au-delà desquelles le jeton n’est plus valide;
  • état est l’état du jeton
  • sable est quelques caractères aléatoires

Ensuite, cette chaîne est chiffrée à l’aide d’un algorithme de chiffrement unidirectionnel (la clé de chiffrement est uniquement connue du côté Serveur) et le buffer résultant est converti en une chaîne Base64.

Le “sable” est utilisé pour s’assurer que 2 jetons, générés avec les mêmes informations, ne seront pas identiques.

HTTP helper class

Voici un exemple de code source côté client qui implémente les rubriques couvertes dans cet article.

Il faut que les 3 librairies suivantes soient installées et référencées dans votre pubspec.yaml.

dependencies:
  flutter:
    sdk: flutter

  device_info: ^0.1.2
  shared_preferences: ^0.4.0
  http: ^0.11.3+16	

En outre, ce code suppose que le serveur renvoie toujours un objet JSON:

jsonObject = {
	status: 'result status',
	data:   'data, returned by the Server'
};
/**
 * Module that handles all communications with the server
 */
import 'dart:async';
import 'dart:io';
import 'dart:convert';
import 'package:device_info/device_info.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:http/http.dart' as http;

// the unique ID of the application
const String _applicationId = "my_application_id";

// the storage key for the token
const String _storageKeyMobileToken = "token";

// the URL of the Web Server
const String _urlBase = "https://www.myserver.com";

// the URI to the Web Server Web API
const String _serverApi = "/api/mobile/";

// the mobile device unique identity
String _deviceIdentity = "";

/// ----------------------------------------------------------
/// Method which is only run once to fetch the device identity
/// ----------------------------------------------------------
final DeviceInfoPlugin _deviceInfoPlugin = new DeviceInfoPlugin();

Future<String> _getDeviceIdentity() async {
  if (_deviceIdentity == '') {
    try {
      if (Platform.isAndroid) {
        AndroidDeviceInfo info = await _deviceInfoPlugin.androidInfo;
        _deviceIdentity = "${info.device}-${info.id}";
      } else if (Platform.isIOS) {
        IosDeviceInfo info = await _deviceInfoPlugin.iosInfo;
        _deviceIdentity = "${info.model}-${info.identifierForVendor}";
      }
    } on PlatformException {
      _deviceIdentity = "unknown";
    }
  }

  return _deviceIdentity;
}

/// ----------------------------------------------------------
/// Method that returns the token from Shared Preferences
/// ----------------------------------------------------------

Future<SharedPreferences> _prefs = SharedPreferences.getInstance();

Future<String> _getMobileToken() async {
  final SharedPreferences prefs = await _prefs;

  return prefs.getString(_storageKeyMobileToken) ?? '';
}

/// ----------------------------------------------------------
/// Method that saves the token in Shared Preferences
/// ----------------------------------------------------------
Future<bool> _setMobileToken(String token) async {
  final SharedPreferences prefs = await _prefs;

  return prefs.setString(_storageKeyMobileToken, token);
}

/// ----------------------------------------------------------
/// Http Handshake
///
/// At application start up, the application needs to synchronize
/// with the server.
/// How does this work?
///   - A. If a previous token exists, the latter is sent to
///   -   the server to be validated.  If the validation is Ok,
///   -   the user is re-authenticated and a new token is returned
///   -   to the application.  The application then stores it.
///
///   - B. If no token exists, the application sends a request
///   -   for a new token to the server, which returns the
///   -   the requested token.  This token will be saved.
/// ----------------------------------------------------------
Future<String> handShake() async {
  String _status = "ERROR";

  return ajaxGet("handshake").then((String responseBody) async {
    Map response = json.decode(responseBody);
    _status = response["status"];
    switch (_status) {
      case "REQUIRES_AUTHENTICATION":
        // We received a new token, so let's save it.
        await _setMobileToken(response["data"]);
        break;

      case "INVALID":
        // The token we passed in invalid ??  why ?? somebody played with the local storage?
        // Anyways, we need to remove the previous one from the local storage,
        // and proceed with another handshake
        await _setMobileToken("");
        break;
		
      //TODO: add other cases
    }

    return _status;
  }).catchError(() {
    return "ERROR";
  });
}

/// ----------------------------------------------------------
/// Http "GET" request
/// ----------------------------------------------------------
Future<String> ajaxGet(String serviceName) async {
  var responseBody = '{"data": "", "status": "NOK"}';
  try {
    var response = await http.get(_urlBase + '/$_serverApi$serviceName',
        headers: {
          'X-DEVICE-ID': await _getDeviceIdentity(),
          'X-TOKEN': await _getMobileToken(),
          'X-APP-ID': _applicationId
        });

    if (response.statusCode == 200) {
      responseBody = response.body;
    }
  } catch (e) {
    // An error was received
    throw new Exception("AJAX ERROR");
  }
  return responseBody;
}

/// ----------------------------------------------------------
/// Http "POST" request
/// ----------------------------------------------------------
Future<Map> ajaxPost(String serviceName, Map data) async {
  var responseBody = json.decode('{"data": "", "status": "NOK"}');
  try {
    var response = await http.post(_urlBase + '/$_serverApi$serviceName',
        body: json.encode(data),
        headers: {
          'X-DEVICE-ID': await _getDeviceIdentity(),
          'X-TOKEN': await _getMobileToken(),
          'X-APP-ID': _applicationId,
          'Content-Type': 'application/json; charset=utf-8'
        });
    if (response.statusCode == 200) {
      responseBody = json.decode(response.body);

      //
      // If we receive a new token, let's save it
      //
      if (responseBody["status"] == "TOKEN") {
        await mid.setMobileToken(responseBody["data"]);

        // TODO: rerun the Post request
      }
    }
  } catch (e) {
    // An error was received
    throw new Exception("AJAX ERROR");
  }
  return responseBody;
}

Conclusion

Ceci complète l’introduction à la notion de communication par jeton.

Dans un prochain article, j’expliquerai la partie Serveur de la communication.

En attendant, restez à l’écoute et heureux codage.