Implémenter une authentification par token sur un projet .NET Core


Préface

Il est parfois bon de se reposer des questions très simples : qu'est ce que l'authentification, et pourquoi avons nous besoin de cette couche dans une application?

Si l'on en croit la définition de wikipédia : "L'authentification pour un système informatique est un processus permettant au système de s'assurer de la légitimité de la demande d'accès faite par une entité (être humain ou un autre système...) afin d'autoriser l'accès de cette entité à des ressources du système"

Concrètement, livrer un projet web à un client demande de répondre (entre autres) à ces questions simples :
  • l'application est elle publique? C'est à dire, faut-il limiter les accès, ou permettre à tout un chacun de l'utiliser?
  • si l'application est privée, existe -il différents types d'utilisateur?
  • Quel et le type de l'application? Site web, application web, web api, web services( ...) ? Un mélange de plusieurs types?
  • A-t-elle besoin d'ouvrir des accès ponctuels?
  • La gestion des utilisateurs doit elle se faire dans l'application?

La réponse à ces simples questions permet d'aborder plus sereinement le type d'authentification qui sera adapté à notre projet.

Introduction


A la création d'un nouveau projet web en ASP.NET Core, visual studio propose de mettre en place (ou non) un type d'authentification :


Voyons rapidement la différence entre ces différentes options :

  • Pas d'authentification :  bravo, nous vivons dans un monde libre, tout le monde pourra accéder à cette application et profiter de l'ensemble des fonctionnalités sans restrictions. Même votre petite soeur.
  • Individual User Accounts :  oldies but goodies, vous voulez protéger votre application, choisir de gérer vos utilisateurs, ou d'implémenter l'authentification via des systèmes tiers (Facebook, twitter, google, microsoft etc.. etc..). En choisissant cette option, vous implémentez la majorité du code.
  • Work and School account :  option récente, celle ci vous permet de vous authentifier en utilisant Active Directory, Azure Active Directory (AAD) ou office 365, les deux dernières options se faisant via azure et le reseau.
  • Windows Authentication : le paramètre est limité, vous développez une application intranet et vous voulez utiliser l'authentification windows.
Comme vous pouvez le voir sur la capture d'écran, suivant le choix de l'application, certaines options sont disponibles.. ou non !

Dans le cadre de notre exemple, notre application mixe une application web et une api web.

Nous avons commencé par développer l'application web, et de par les contraintes de notre client, notre choix s'est porté sur une authentification par compte individuels. Nous avons donc utilisé de nombreux aspects d'Identity, et si ce n'est pas le sujet de ce billet je vous conseille très fortement la série d'excellents article postés par Andrew Lock. Pour résumer, nous nous sommes basés sur une authentification par mots de passe, avec la gestion de cookies, une authentification par gestion de "claims", et des autorisations par "policy" mixant des rôles et des "claims".

Ces notions ne sont pas forcément transparentes ou traduisibles. Mais après un effort de recherche, voici l'image que je préfère :

Lorsque vous arrivez à l'aéroport avec votre billet d'avion, on vous demande votre carte d'identité pour vérifier votre identité (c'est le process d'authentification)

Les éléments qui vont permettre cette authentification, comme votre nom, votre prénom, votre date de naissance représentent les claims.

Par la suite, pour acheter des produits en duty-free, on ne vous demandera pas votre carte -vous avez déjà pénétré dans un espace sécurisé- mais votre billet d'avion international, c'est le process d'autorisation : pouvez-vous accéder à ce que vous avez demandé ? 

Et l'api arriva

A la suite de la mise en place de l'application web, nous avons eu besoin de mettre en place une api au sein de l'application afin d'alimenter en données les clients de notre client.

Nous avons donc monté une api au sein de l'application. Après une première implémentation sans sécurisation, il est rapidement apparu nécessaire de sécuriser et d'autoriser les différentes méthodes de ce projet REST.

Dans ce cadre, nous ne pouvions plus utiliser notre gestion de compte car les appels à notre api auraient nécessités des interventions manuelles au sein d'un processus entièrement automatisé.

Pour suivre les usages -il n'y a pas de norme REST- et après une tentative avortée d'utiliser Azure Active Directory -très lourde machine de guerre- nous avons décidé d'utiliser la souplesse de jetons d'authentification, les fameux tokens.

Principe de fonctionnement

Les externes qui souhaitent utiliser notre Api font une première demande qui permettra de valider leur adresse mail. Tout le monde ne peut donc pas accéder à notre api.

Cependant, une méthode est accessible (avec une extension adéquate, ici Advanced REST Client) afin de tester l'api :



Dans un second temps, après validation de leur adresse, l'api est disponible.

Une première méthode est disponible et non sécurisée pour une volonté de démonstration. Elle 
renvoie un simple objet json.

Toujours dans le souci de démonstration, une seconde méthode est disponible afin de tester la sécurisation. De base, elle renvoie un statut 401 : unauthorised

Afinde lancer ses différentes requêtes sécurisées, le client doit donc faire une première demande de token à un adresse du type : "api/auth/token" en postant en paramètre son email et son mot de passe.

L'api répond alors un objet JSON du type (ne l'essayez  pas c'est un faux ^^):

{
"username": "my_super_username@mydomain.com",
"token": "efKhbGciOiJIUzI1NiIsInG5cCI6FkpXVCJ9.eyJ1bmlxdWVfbmFtZSI6InVfc19lX3JfNjY2QHlvcG1haWwuY29tIiwibmJmIjoxNDk1MTA3MTM4LCJleHAiOjE0OTUxOTM1MzgsImlhdCI6MTQ5NTEwNzEzOCwiaXNzIjoiU2VjdXJlIEltbW8gLSBjdG91dHZlcnQiLCJhdWQiOiJTZWN1cmUgSW1tbyBhcGkgZ3Vlc3RzIn0.pjxFGrJGWQNV2-TFyq2pTAtrq8jwiPDxvIgdTKzsSnk",
"expiresOn": "2017-05-19T11:32:18.6944363Z"
}

A partir de ces infos, l'utilisateur va pouvoir construire ses requêtes ultérieures en incluant le token de sécurité, qui contient les informations nécessaires et suffisantes pour l'identifier comme nous le verrons par la suite.


Gimme some code

Pour les deux exemples précédents, voici le code :


using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SecureImmo.Web.Utils.Identity;

namespace SecureImmo.Web.Controllers
{
    public class AuthController : Controller
    {

        private readonly IApiManager _apiManager;

        public AuthController(IApiManager apiManager)
        {
            _apiManager = apiManager;
        }
        
        [HttpPost,AllowAnonymous]
        [Route("api/auth/")]
        public async Task<IActionResult> GetToken(string email, string password)
        {
            if (email == null) return BadRequest("email is mandatory");
            if (password == null) return BadRequest("password is mandatory");

            var result = await _apiManager.GetToken(email, password, true);
            if (result == null)
                return Unauthorized();
            return Ok(result);
        }
        
        [HttpPost, AllowAnonymous]
        [Route("api/auth/HelloWorld")]
        public IActionResult PostHelloWorld(int id)
        {
            return Ok(new { message = "Welcome anonymous", values = id });
        }
        
        [HttpGet, AllowAnonymous]
        [Route("api/auth/HelloWorld")]
        public IActionResult GetHelloWorld(int id)
        {
            return Json(new { message = "Welcome anonymous", values = id });
        }

    }
}

On remarquera l'utilisation de la classe OkObjectResult qui produit une réponse avec un statut 200, ainsi que l'objet UnauthorizedResult pour un statut 401, ou encore BadRequestObjectResult pour un statut 400.

Ok, mais comment produire un token?

Nous voici donc au coeur du problème, nous avons une application web et une api. Nous avons donc une partie gérée par Identity, et une partie qui doit être gérée avec un token.

Dans un premier temps, nous allons nous intéresser à la production d'un token :

    public interface IApiManager
    {
        /// <summary>
        /// interface de gestion de l'api :  générer un token
        /// </summary>
        /// <param name="username">nom utilisateur</param>
        /// <param name="password">User déclenchant l'action</param>
        /// <param name="checkPassword">verifier...ou pas!</param>
        /// <returns></returns>
        Task<TokenDescription> GetToken(string username, string password, bool checkPassword);
    }

Puisque nous sommes dans un projet fonctionnant autour de l'injection de dépendances, on met en place une interface de définition du token.

Puis, son implémentation :


public class ApiManager : IApiManager
    {
        private readonly ApplicationUserManager _userManager;
        private readonly SignInManager<ApplicationUser> _signInManager;
        private readonly AppKeyConfig _config;
        private readonly IStringLocalizer<ApiManager> _localizer;

        public ApiManager(
            IStringLocalizer<ApiManager> localizer,
            ApplicationUserManager userManager,
            IOptions<AppKeyConfig> config,
            SignInManager<ApplicationUser> signInManager)
        {
            _localizer = localizer;
            _userManager = userManager;
            _signInManager = signInManager;
            _config = config.Value;
        }
        public async Task<TokenDescription> GetToken(string username, string password, bool checkPassword)
        {
            var asUser = await _userManager.FindByEmailAsync(username);
            if (asUser == null)
            {
                return null;
            }

            //est ce que cet utilisateur peut se logguer? cad : est ce qu'il a confirmé et modifié son login? sinon erreur
            if (await _signInManager.CanSignInAsync(asUser))
            {
                string token = "error";
                DateTime? expires = DateTime.UtcNow;
                SignInResult res = new SignInResult();
                if (checkPassword)
                {
                    res = await _signInManager.CheckPasswordSignInAsync(asUser, password, true);
                }

                if (!checkPassword || res.Succeeded)
                {
                    var key = Encoding.UTF8.GetBytes(_config.Sentence);
                    var signingKey = new SymmetricSecurityKey(key);
                    var tokenOptions = new TokenAuthOptions
                    {
                        Audience = _config.Audience,
                        Issuer = _config.Issuer,
                        SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256)
                    };

                    expires = expires.Value.AddDays(1);

                    token = RsaKeyManager.GetToken(asUser, tokenOptions, expires);

                    return new TokenDescription { Token = token, ExpiresOn = expires.Value, Username = asUser.Email};

                }
            }

            return null;
        }
    }

Quelques explications :

var asUser = await _userManager.FindByEmailAsync(username);

Ici nous vérifions via Identity, si l'utilisateur qui demande son jeton a bien été créé !

await _signInManager.CanSignInAsync(asUser)

L'utilisateur a bien été trouvé, mais nous vérifions aussi, s'il a droit de se connecter, c'est à dire si son email a bien été validé, s'il n'a pas été suspendu, ou s'il n'a pas bloqué son compte suite à un trop grand nombre d'erreurs de mots de passe (attaque brut force)

await _signInManager.CheckPasswordSignInAsync(asUser, password, true);

Le code est explicite, on vérifie aussi son mot de passe. Le booléen qui permet de by-passer cette vérification permet à un utilisateur de haut niveau d'obtenir un jeton sans connaître le mot de passe du client.

Dans un dernier temps, nous produisons le jeton :


    var key = Encoding.UTF8.GetBytes(_config.Sentence);
    var signingKey = new SymmetricSecurityKey(key);
    var tokenOptions = new TokenAuthOptions
    {
        Audience = _config.Audience,
        Issuer = _config.Issuer,
        SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256)
    };

On utilise pour ce faire une clé de sécurité SymmetricSecurityKey qui hérite de la classe SecurityKey. Cette clé utilise une phrase secrète (enregistrée dans votre secret.json par exemple) et permet l'instanciation de la classe SigningCredentials.

Cet objet sera passé en paramètre d'un manager dont voici la méthode principale :


        public static string GetToken(ApplicationUser user, TokenAuthOptions options,  DateTime? expires)
        {
            var handler = new JwtSecurityTokenHandler();

            var allClaims = user.Claims.Select(m => m.ToClaim()).ToArray();
            
            // Here, you should create or look up an identity for the user which is being authenticated.
            // For now, just creating a simple generic identity.
            ClaimsIdentity identity = new ClaimsIdentity(new GenericIdentity(user.Email, "TokenAuth"),  allClaims);

            var securityToken = handler.CreateToken(new SecurityTokenDescriptor
            {
                Issuer = options.Issuer,
                Audience = options.Audience,
                SigningCredentials = options.SigningCredentials,
                Subject = identity,
                Expires = expires
            });
            
            return handler.WriteToken(securityToken);
        }

Le handler JwtSecurityTokenHandler va permettre de produire un token, et l'on remarquera que l'on peut introduire dans ce token toutes les informations nécessaires à l'identification et la validation. On peut ainsi attacher un certain nombre d'informations qui seront décodées à la validation du token :
  • un nom d'utilisateur
  • la date d'expiration
  • des identifiants d'objets divers et variés
  • ma date d'anniversaire
  • l'age du capitaine

Comment utiliser ce token?

Encore une fois, avec l'extension adéquate (vous pouvez aussi utiliser post man ou soap ui ..)
Il suffit de passer un header (copier le texte et ne pas inclure les guillemets) :

Authorization: bearer mon-token-tres-tres-tres-long



Comment mettre en place la validation du token

Première étape, dans le ConfigureServices du stratup.cs, il faut AVANT  AddMvc le service suivant :

            services.AddAuthorization(auth =>
            {
                auth.AddPolicy("Bearer", new AuthorizationPolicyBuilder()
                    .AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme)
                    .RequireAuthenticatedUser().Build());
            });

Ce morceau de code permet de définir la police suivante (dans le code de l'api) :

[Authorize("Bearer")]

Chaque controller et chaque action présentant cette annotation sera donc sécurisée et nécessitera un jeton valide.


Nous avons donc défini une nouvelle "policy", il faut maintenant ajouter un nouveau "middleware" de prise en charge du jeton.

Dans le cas le plus simple, c'est à dire une web api sans Identity, il suffirait de rajouter le code suivant dans le configure du startup.cs :


c.UseJwtBearerAuthentication(new JwtBearerOptions
{
    TokenValidationParameters = new TokenValidationParameters
    {
        IssuerSigningKey = signingKey,
        ValidAudience = appkeys.Value.Audience,
        ValidIssuer = appkeys.Value.Issuer,

        // When receiving a token, check that it is still valid.
        ValidateLifetime = true,
        LifetimeValidator = (DateTime? notBefore, DateTime? expires, SecurityToken securityToken, TokenValidationParameters validationParameters) =>
        {
            var userName = "";
            dynamic dyn = JObject.Parse(securityToken.ToJson());

            for (int i = 0; i < dyn["Claims"].Count; i++)
            {
                if (dyn["Claims"][i]["Type"] == "unique_name")
                {
                    userName = dyn["Claims"][i]["Value"];
                    break;
                }
            }
            var user = userManager.FindByName(userName);

            if (user == null)
            {
                throw new AuthenticationException("User not found - Not authorized");
            }

            if (!user.EmailConfirmed)
                throw new AuthenticationException("Please confirm email first - Not authorized");


            if (user.IsSuspended)
                throw new AuthenticationException("Your account is suspended/locked - Not authorized");

            var roles = roleManager.RolesIds(new List<string> { RoleHelper.ApiUser });
            var authorized = user.Roles.Select(m => m.RoleId).Any(roleId => roles.Contains(roleId));

            return authorized;
        },

        // This defines the maximum allowable clock skew - i.e. provides a tolerance on the token expiry time 
        // when validating the lifetime. As we're creating the tokens locally and validating them on the same 
        // machines which should have synchronised time, this can be set to zero. Where external tokens are
        // used, some leeway here could be useful.
        ClockSkew = TimeSpan.FromMinutes(30),

    }

La partie importante étant le LifetimeValidator, qui permet de valider le jeton selon vos propres règles. Dans notre cas, on vérifie de nouveau que l'utilisateur existe, et surtout que son rôle est bien parmi ceux autorisés!

Mais j'ai terminé alors? 

Non.. pas tout à fait. n'oubliez pas la coexistence de deux systèmes d'authentification... En effet dans le middleware il est inclus quelque-chose comme : UseIdentity, qui implémente l'authentfication par formulaire et cookie.

De ce fait, sur les requêtes entrantes, il est défini par défaut (via le code) que tous les utilisateurs de l'application doivent être authentifiés. Du coup, à chaque requête, vous serez redirigés vers la page de login.

Or nous avons besoin de ne pas être authentifié pour obtenir un token...

Le contournement

dans la méthode Configure  on injecte IApplicationBuilder. On nommera celui ci "app" pour la suite.

Une méthode est disponible et s'appelle MapWhen. Un rapide coup d’œil à sa définition permet de voir qu'en fonction d'un prédicat, elle opère une branche dans le middleware. Cette méthode crée un enchaînement spécifique de briques du middleware en fonction d'une condition.


Dans notre  cas, on va créer deux branches, une pour gérer toutes les urls de l'api (elles ont toutes une route commençant par "/api") qui va ajouter la brique de validation de notre token, et une seconde pour toutes les autres urls, qui utilisera la brique Identity classique!

Il est important de noter qu'il faut ajouter toutes les briques dans l'ordre à partir de la branche...
Et, qu'il faut gérer l'autre branche, en incluant toutes les briques nécessaire à celle-ci (UseCookieAuthentication, UseIdentity...)



            app.MapWhen(context => context.Request.Path.StartsWithSegments(new PathString("/api")), c =>
            {

                c.UseCookieAuthentication();

                var key = Encoding.UTF8.GetBytes(appkeys.Value.Sentence);
                var signingKey = new SymmetricSecurityKey(key);

                var locOptions = app.ApplicationServices.GetService<IOptions<RequestLocalizationOptions>>();
                c.UseRequestLocalization(locOptions.Value);

                c.UseJwtBearerAuthentication(new JwtBearerOptions
                {
                    TokenValidationParameters = new TokenValidationParameters
                    {
                        IssuerSigningKey = signingKey,
                        ValidAudience = appkeys.Value.Audience,
                        ValidIssuer = appkeys.Value.Issuer,

                        // When receiving a token, check that it is still valid.
                        ValidateLifetime = true,
                        LifetimeValidator = (DateTime? notBefore, DateTime? expires, SecurityToken securityToken, TokenValidationParameters validationParameters) =>
                        {
                            var userName = "";
                            dynamic dyn = JObject.Parse(securityToken.ToJson());

                            for (int i = 0; i < dyn["Claims"].Count; i++)
                            {
                                if (dyn["Claims"][i]["Type"] == "unique_name")
                                {
                                    userName = dyn["Claims"][i]["Value"];
                                    break;
                                }
                            }
                            var user = userManager.FindByName(userName);

                            if (user == null)
                            {
                                throw new AuthenticationException("User not found - Not authorized");
                            }

                            if (!user.EmailConfirmed)
                                throw new AuthenticationException("Please confirm email first - Not authorized");


                            if (user.IsSuspended)
                                throw new AuthenticationException("Your account is suspended/locked - Not authorized");

                            var roles = roleManager.RolesIds(new List<string> { RoleHelper.ApiUser });
                            var authorized = user.Roles.Select(m => m.RoleId).Any(roleId => roles.Contains(roleId));

                            return authorized;
                        },

                        // This defines the maximum allowable clock skew - i.e. provides a tolerance on the token expiry time 
                        // when validating the lifetime. As we're creating the tokens locally and validating them on the same 
                        // machines which should have synchronised time, this can be set to zero. Where external tokens are
                        // used, some leeway here could be useful.
                        ClockSkew = TimeSpan.FromMinutes(30),

                    }

                });

                c.UseMvc();
            });

Et maintenant?

Il ne vous reste plus qu'à tester, prendre un café, et pour moi de vous remercier de m'avoir lu jusqu'ici...




Commentaires

Posts les plus consultés de ce blog

Framework Bootstrap - Version LESS/SASS

Gérer la configuration des machines virtuelles avec Azure Automation et PowerShell DSC