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

Cet article couvre la partie Serveur de la communication basée sur les tokens. La première partie de l’article couvre l’introduction à ce genre de communication et la partie “Cliente”.

Délibérément, j’ai opté pour une implémentation en .NET, car il n’est pas évident de trouver des articles utilisant cette technologie.

Cet article est uniquement destiné à donner une implémentation d’une communication basée sur des jetons entre une application mobile et un serveur.

Résumé de l’article précédent

Token

Un jeton (= “token”) est une information partagée entre l’application mobile et le serveur pour sécuriser les communications. Il contient les données suivantes:

{device_id}~{application_id}~{user_id}~{expiration_timestamp}~{token_type}~{sand}
  • device_id: identité unique du Smartphone. Cette information est fournie par l’Application Mobile
  • application_id: identité unique de l’application. Cette information est fournie par l’Application Mobile et partagée par le Serveur
  • user_id: identifiant de l’utilisateur (une fois identifié). Cette information est ajoutée par le Serveur
  • expiration_timestamp: heure et date (= “timestamp”) au-delà de laquelle le token n’est plus valide. Cette information est générée par le Serveur.
  • token_type: type de token. Généré par le Serveur
  • sand: caractères de remplissage, aléatoires, également générés par le Serveur

Un jeton est chiffré à l’aide d’un algorithme de chiffrement unidirectionnel (la clé de chiffrement n’est connue que du côté serveur).

Handshake

Dans l’article précédent, nous avons vu que pour sécuriser la communication, une première communication (appelée handshake) se produisait entre l’application mobile et le serveur.

Lors de cette première communication “handshake”, l’application mobile envoie les informations suivantes au serveur, via l’en-tête de la requête (= “header”):

  • identité de l’application
  • identité unique du Smartphone
  • un jeton précédemment utilisé (si jamais il en existe un)

Lorsque le serveur reçoit une telle requête, il doit:

  • récupérer les informations à partir de l’en-tête (=”header”) de la requête
  • valider ces informations
  • générer un nouveau jeton, basé sur les résultats de la validation ou rejeter la requête

Autres communications

Toute autre communication doit suivre ce modèle strict:

L’application mobile envoie:

  • Dans l’en-tête de la requête:

    • l’identification de l’application
    • l’identité unique du Smartphone
    • le jeton
  • Dans le corps de la requête (=”request body”):

    • l’information à faire transiter de l’application mobile vers le serveur

Lorsque le serveur reçoit une requête:

  • Il récupère les données à partir de l’en-tête (=”header”) de la requête
  • Il valide ces données
  • Si la validation échoue, il rejète la requête
  • Si la validation réussit, it traite la requête et renvoie la réponse à l’application mobile

Implémentation

Cette section montre une implémentation de base de cette théorie.

Gestion des Tokens (Jetons)

Le code suivant correspond à la classe d’aide, responsable du traitement des Tokens(Jetons).

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Web;
using System.Web.Security;

namespace MyApplication.Helpers.Mobile
{
    /// <summary>
    /// Handles the MobileToken
    /// 
    /// Resulting token is a base64 string.
    /// The content is the following:
    ///     {deviceId}~{applicationId}~{userId}~{expirationTimestamp}~{status}~{sand}
    /// To obtain the base64 string, we encrypt the content by the deviceId + ENCRYPTION_KEY
    /// </summary>
    public class MobileToken
    {
        public string Token { get; set; }
        public MobileTokenStatus Status { get; set; }
        public DateTime ExpirationDateTime { get; set; }
        public string ApplicationId { get; set; }
        public string UserId { get; set; }
        public string Sand { get; set; }
        public string DeviceId { get; set; }


        private const string MOBILE_DEVICE_ID = "X-DEVICE-ID";
        private const string MOBILE_APP_ID = "X-APP-ID";
        private const string MOBILE_TOKEN = "X-TOKEN";
        private const string REFERENCE_APP_ID = "MY_APPLICATION_ID";
        private const string ENCRYPTION_KEY = "MyEncrypTionKey";
		
        public MobileToken()
        {
            // Empty
        }

        /// <summary>
        /// Retrieve the token from a Http Request
        /// We need to validate this token
        /// </summary>
        /// <param name="request"></param>
        public MobileToken(HttpRequestMessage request)
        {
            IEnumerable<string> headerValues;

            //
            // Retrieve the device ID
            //
            string _deviceId = "";
            if (request.Headers.TryGetValues(MOBILE_DEVICE_ID, out headerValues))
            {
                _deviceId = headerValues.FirstOrDefault();
            }

            //
            // If there is no device ID, this is a fatal ERROR
            // This should normally NEVER happens since we have a filter to make sure all requests issued by a mobile device contain this intel
            //
            if (string.IsNullOrEmpty(_deviceId))
            {
                Status = MobileTokenStatus.INVALID;
                return;
            }

            //
            // Retrieve the application ID
            //
            string _appId = "";
            if (request.Headers.TryGetValues(MOBILE_APP_ID, out headerValues))
            {
                _appId = headerValues.FirstOrDefault();
            }

            //
            // If there is no application ID or if the latter does not correspond to the reference one, this is a fatal ERROR
            //
            if (string.IsNullOrEmpty(_appId) || _appId != REFERENCE_APP_ID)
            {
                Status = MobileTokenStatus.INVALID;
                return;
            }
						
            //
            // Retrieve the token from the headers
            //
            string _token = "";
            if (request.Headers.TryGetValues(MOBILE_TOKEN, out headerValues))
            {
                _token = headerValues.FirstOrDefault();
            }

            if (string.IsNullOrEmpty(_token))
            {
                Status = MobileTokenStatus.NO_TOKEN;
                DeviceId = _deviceId;       // Save the device id for later re-use
                return;
            }

            //
            // Let's open the token to validate it
            //
            _validate(_token, _deviceId);
        }

        /// <summary>
        /// Opens an existing token
        /// </summary>
        /// <param name="token"></param>
        /// <param name="deviceId"></param>
        private void _validate(string token, string deviceId)
        {
            try
            {
                var bytes = Convert.FromBase64String(token);
                var decrypted = Encoding.UTF8.GetString(MachineKey.Unprotect(bytes, deviceId.Replace('~', '|') + ENCRYPTION_KEY));

                //{deviceId}~{applicationId}~{userId}~{expirationTimestamp}~{status}~{sand}
                string[] parts = decrypted.Split('~');

                if (deviceId != parts[0])
                {
                    // Decrypted token is not ok
                    Status = MobileTokenStatus.INVALID;
                    return;
                }

                // Get the other values
                DeviceId = deviceId;
                ApplicationId = Convert.ToInt64(parts[1]);
                UserId = parts[2];
                ExpirationDateTime = Convert.ToDateTime(parts[3]);

                Status = (MobileTokenStatus)Convert.ToInt32(parts[4]);

                Sand = parts[5];

                // Validate the application Id
                if (ApplicationId != REFERENCE_APP_ID)
                {
                    // Application_id is not ok
                    Status = MobileTokenStatus.INVALID;
                    return;
                }
				
                // Let's now validate the expiration timestamp
                TimeSpan span = (DateTime.Now - ExpirationDateTime);
                if (span.Seconds > 0)
                {
                    // The token has expired
                    Status = MobileTokenStatus.EXPIRED;
                    DeviceId = deviceId;       // Save the device id for later re-use
                }
                else if (Status != MobileTokenStatus.REQUIRES_AUTHENTICATION)
                {
                    // The token is valid
                    Status = MobileTokenStatus.OK;
                }
            }
            catch (Exception ex)
            {
                // Decrypted token is not ok
                Status = MobileTokenStatus.INVALID;
            }
        }

        /// <summary>
        /// Generates a token that does not contain any user related information
        /// This token is only used to provide authentication mechanism
        /// </summary>
        /// <param name="deviceId"></param>
        public void Generate(string deviceId)
        {
            Status = MobileTokenStatus.REQUIRES_AUTHENTICATION;
            UserId = "";
            ExpirationDateTime = DateTime.Now.AddHours(1);
            DeviceId = deviceId;

            _generateToken();
        }

        public void Generate(string deviceId, string userId)
        {
            Status = MobileTokenStatus.OK;
            UserId = userId;
            ExpirationDateTime = DateTime.Now.AddMonths(12);
            DeviceId = deviceId;

            _generateToken();
        }

        private void _generateToken()
        {
            _getSand();
            //{deviceId}~{applicationId}~{userId}~{expirationTimestamp}~{status}~{sand}
            string tokenInClear = $"{DeviceId}~{REFERENCE_APP_ID}~{UserId}~{ExpirationDateTime}~{(int)Status}~{Sand}";

            var originalStringBytes = Encoding.UTF8.GetBytes(tokenInClear);
            var encrypted = MachineKey.Protect(originalStringBytes, DeviceId + ENCRYPTION_KEY);

            Token =  Convert.ToBase64String(encrypted);
        }

        /// <summary>
        /// Generates some Sand (random padding value) so that a token, even with the very same pieces of information
        /// won't be the same at next time
        /// </summary>
        private void _getSand()
        {
            using (RandomNumberGenerator rng = new RNGCryptoServiceProvider())
            {
                byte[] tokenData = new byte[32];
                rng.GetBytes(tokenData);

                Sand = Convert.ToBase64String(tokenData);
            }
        }
    }

    public enum MobileTokenStatus: int
    {
        INVALID = -2,
        NO_TOKEN = -1,
        OK = 0,
        EXPIRED = 1,
        REQUIRES_AUTHENTICATION = 2
    }
}

Le Controller (=”contrôleur”)

Voici le code du controller:

namespace MyApplication.Controllers.api
{
    [WebApiMobileDeviceId]
    [AllowAnonymous]
    [RoutePrefix("api/Mobile")]
    public class MobileController : ApiController
    {
        [HttpGet]
        [Route("handshake")]
        public WebServiceResponseDto handshake()
        {
            WebServiceResponseDto response = new WebServiceResponseDto();
            MobileToken token = new MobileToken(Request);
            switch (token.Status)
            {
                case MobileTokenStatus.NO_TOKEN:
                case MobileTokenStatus.EXPIRED:
                    // There is no token or it is expired, so we need to generate one
                    token.Generate(token.DeviceId);

                    // And pass it to the application
                    response.data = token.Token;
                    break;

                case MobileTokenStatus.INVALID:
                    // The token is invalid
                    break;

                case MobileTokenStatus.OK:
                    // The token is valid
                    break;
            }

            response.status = token.Status.ToString();
            return response;
        }

        [HttpPost]
        [Route("login")]
        public async Task<WebServiceResponseDto> login([FromBody] MobileLoginDto model)
        {
            WebServiceResponseDto response = new WebServiceResponseDto(){status = WebServiceResponseDto.NOK};
            MobileToken token = new MobileToken(Request);
            if (token.Status == MobileTokenStatus.REQUIRES_AUTHENTICATION || token.Status == MobileTokenStatus.NO_TOKEN)
            {
                //
                // Let's proceed with the login
                // (insert your authentication code here)
                //
                string userId = _doLogin(model.Email, model.Password);

                if (userId != "")
                {
                    //
                    // We now need to update the token
                    //
                    token.Generate(token.DeviceId, webUser.UserId);

                    //
                    // As everything went ok, let's return the token
                    //
                    response.data = token.Token;
                    response.status = WebServiceResponseDto.TOKEN;
                }
            }
            return response;
        }

        [HttpGet]
        [Route("MethodABC/{data}")]
        public WebServiceResponseDto MethodABC(string data)
        {
            MobileToken token = new MobileToken(Request);

            var service = new MethodServices();
            return service.DoSomething(token.UserId, data);
        }
	}
}

Filtres

Afin d’augmenter la sécurité, vous pouvez également implémenter des filtres qui garantissent que certaines informations sont toujours présentes dans l’en-tête.

Le code suivant donne un exemple de ce filtre, qui rejette toute demande où l’en-tête ne contiendrait aucune identité de périphérique.

namespace MyApplication.Filters
{
    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = true, AllowMultiple = true)]
    public class WebApiMobileDeviceIdAttribute : System.Web.Http.Filters.ActionFilterAttribute
    {
        public override void OnActionExecuting(HttpActionContext actionContext)
        {
            //
            // Try to retrieve the mobile device UUID from the header
            //
            IEnumerable<string> headerValues;
            string mobileUuid = "";
            if (actionContext.Request.Headers.TryGetValues("X-DEVICE-ID", out headerValues))
            {
                mobileUuid = headerValues.First();
            }

            //
            // If we could not find it, then no need to go any further
            //
            if (String.IsNullOrEmpty(mobileUuid))
            {
                actionContext.Response = new HttpResponseMessage()
                {
                    StatusCode = HttpStatusCode.Forbidden
                };
            }
            else
            {
                base.OnActionExecuting(actionContext);
            }
        }
    }
}

Conclusion

Comme vous pouvez le voir, grâce à la notion de jeton, il est très difficile de casser ou de pirater la communication.

J’espère que le code est explicite et facile à comprendre.

Restez à l’écoute pour d’autres articles et, comme d’habitude, je vous souhaite un bon codage.