ASP.Net Core – authentification et autorisation – 2 : ASP.Net Core Identity avec une Web API (.NET 5)

Dans le premier article dédié à l’authentification, nous avons vu une version très simple permettant d’identifier un utilisateur via un cookie, ainsi que les principes de base qui vont avec. Pour rappel, l’élément important est le « claim » qui représente une information d’un utilisateur.

Dans ce nouvel article, nous allons aller plus loin pour gérer un ensemble d’utilisateur. En effet, gérer soit même les utilisateurs est assez fastidieux quand le .NET fournit déjà une librairie par défaut qui nous simplifie la vie. Je n’ai trouvé aucun article résumant les choses en partant d’un projet vierge.

Vous pouvez télécharger le code source complet cet article.

1. ASP.Net Core Identity ?

La page du site de Microsoft présente déjà la librairie. Il s’agit d’un middleware permettant la gestion des utilisateurs. Il est capable de fournir des pages par défaut pour gérer la création des utilisateurs, leur connexion … avec des possibilités d’envoi de mail de confirmation …

L’avantage est que la gestion des utilisateurs est très simple, l’inconvénient est que la boîte noire contient un tas de fonctionnalités plus ou moins utiles. Cet article va donc présenter les grands points à connaitre pour utiliser ASP.Net Core Identity. Ce passage est obligé pour monter d’un cran et gérer simplement de l’authentification par token json, mais il s’agira d’un prochaine article 😉

ASP.Net Core Identity fournit les services suivants :

  • Un schéma de base de données
  • Un service « UserManager » pour la gestion des utilisateurs (création, recherche …)
  • Un service « RoleManager » pour la gestio des rôles (création, suppression …)
  • Un service « SignInManager » pour la gestion de l’authentification des utilisateurs

2. Création de la solution Web Api et de la base de données

Le premier point est de créer une nouvelle solution de type « API » en ASP.Net Core 5 sans authentification, nous allons tout faire à la main pour bien comprendre le fonctionnement.

La première étape consiste à ajouter les références de package Nuget suivantes dans notre projet :

Microsoft.EntityFrameworkCore
Microsoft.EntityFrameworkCore.SqlServer
Microsoft.AspNetCore.Identity
Microsoft.AspNetCore.Identity.EntityFrameworkCore

Microsoft.EntityFrameworkCore.Design

Le dernier sert uniquement pour que « migration » fonctionne lors de la création de la base de données.

2.1. Création du contexte EntityFramework

Pour que les informations soient persistées, nous devons initialiser un contexte EntityFramework spécial pour la gestion des utilisateurs et des rôle. Créons le fichier « ApplicationDbContext.cs » avec le contenu suivant.

using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace IdentityServer.Models
{
    public class ApplicationDbContext : IdentityDbContext<IdentityUser> {
        public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options) {}
    }
}

Le contexte EntityFramework Identity est décrit dans la classe « IdentityDbContext ». Nous devons créer une classe héritant de ce contexte pour l’utilisée dans notre application. De plus, il prend en paramètre un type d’objet qui doit hériter de la classe « IdentityUser ». Dans l’exemple, nous utilisons directement « IdentityUser », mais il est possible de déclarer un objet spécifique à son application et de lui ajouter d’autres propriétés qui seront persistées.

2.2. Configuration du service et de l’application

Tout d’abord, ajoutons une chaîne de connexion dans le fichier « appsettings.json ».

"ConnectionStrings": {
    "IdentityServerDatabase": "Server=(localdb)\\mssqllocaldb;Database=IdentityServerDatabase;Trusted_Connection=True;"
},

Ensuite, nous pouvons commencer la déclaration des services du fichier « Startup.cs » :

public void ConfigureServices(IServiceCollection services) {
  var connectionString = _configuration.GetConnectionString("IdentityServerDatabase");
  services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(connectionString));
  services.AddIdentity<IdentityUser, IdentityRole>()
          .AddEntityFrameworkStores<ApplicationDbContext>();

  services.AddControllers();
}

Assez classiquement, nous commençons par déclarer le contexte EntityFramework lié à Sql Server. En plus, nous déclarons le middleware « Identity » qui prend 2 types génériques :

  • Le type de classe d’un utilisateur qui doit être le même que celui du contexte, nous mettons donc « IdentityUser »
  • Le type de classe d’un rôle, il est possible d’en définir un personnalité qui hérite de la classe « IdentityRole », à défaut l’utilisateur d' »IdentityRole » est suffisante.

Vous noterez que nous ne faisons pas appel à la méthode « services.AddAuthentification() » car celle-ci est appelée implicitement par le « AddIdentity ». Je vous invite à aller voir le code de la méthode sur GitHub pour voir tout ce qu’elle fait.

Attention, il existe 3 méthodes similaires :

  • AddIdentityCore : gère uniquement les utilisateurs, sans cookie, sans rôles
  • AddIdentity : inclus AddIdentityCore avec la gestion des cookies et des rôles
  • AddDefaultIdentity :inclus AddIdentityCore et des IHM par défaut pour les inscriptions, login …

Enfin, configurons les services actifs.

public void Configure(IApplicationBuilder app, IWebHostEnvironment env) {
   if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); }

   app.UseRouting();
   app.UseAuthentication();
   app.UseEndpoints(endpoints => endpoints.MapControllers());
}

Nous faisons simplement un appel à « UseAuthentification » pour qu’elle soit activée et c’est terminé pour une version simple 🙂

2.3. Création de la base de données

ASP.Net Core Identity utilise « Migration » pour initialiser la base données.

Tout d’abord, si vous n’avez pas les commandes dotnet de migration d’installée (non installées par défaut), il faut installer l’outil « ef » via un terminal.

dotnet tool install --global dotnet-ef

Ensuite, vous pouvez lancer la création de la base de données via un terminal (menu « Affichage » de Visual Studio). Deux points à noter : compilez la solution, et placez-vous bien dans le répertoire du projet concerné (et non au niveau de la solution) .

dotnet ef migrations add InitIdentity
dotnet ef database update

Notre base de données est maintenant initialisée !

3. Création du contrôleur de gestion des utilisateurs

Nous sommes prêts à démarrer notre projet et notre gestion des utilisateurs. Nous créons un nouveau contrôleur API « AuthController ».

3.1. Constructeur

Notre injectons les services de gestion des utilisateurs « UserManager » et d’authentification « SignInManager » dans le contrôleur. Ceux-ci prennent en argument générique le type de la classe représentant un utilisateur.

using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;

namespace IdentityServer.Controllers {
    [Route("api/[controller]")]
    [ApiController]
    public class AuthController : ControllerBase {

        private UserManager<IdentityUser> _userManager;
        private SignInManager<IdentityUser> _signInManager;
        public AuthController(UserManager<IdentityUser> userManager
                             ,SignInManager<IdentityUser> signInManager)
        {
            _userManager = userManager;
            _signInManager = signInManager;
        }
        
    }
}

3.2. Création d’un utilisateur

La première méthode de notre contrôleur sert à créer un utilisateur. Nous allons faire simple en passant un login / mot de passe en paramètre.

[HttpGet, Route("Create")]
public async Task<IActionResult> Create(string login, string password) {
   var model = new IdentityUser { UserName = login };
   var result = await _userManager.CreateAsync(model, password);
   return Ok(result);
}

Notez le passage en argument du mot de passe plutôt que le mettre dans la propriété du modèle pour qu’il soit encrypté.

Avec PostMan, je teste la méthode : https://localhost:44393/api/Auth/Create?login=Clemox&password=P@swword123
Et le résultat est le suivant :

{
    "succeeded": true,
    "errors": []
}

Vous noterez que si vous mettez un mot de passe trop simple, vous aurez des erreurs. Idem si vous essayez de créer 2 fois le même utilisateur. C’est le comportement intégré de ASP.Net Core Identity qui gère cela. Nous verrons dans le dernier paragraphe comment modifier ces comportements.

3.3. Login / logout

Après avoir créé un utilisateur, il est intéressant de pouvoir être authentifier ou terminer sa session. Nous créons ainsi les 2 méthodes liées à ces foncitonnalités.

[HttpGet, Route("Login")]
public async Task<IActionResult> Login(string login, string password) {
   var model = await _userManager.FindByNameAsync(login);
   if (model != null) {
      var result = await _signInManager.PasswordSignInAsync(model, password, false, false);
      return Ok(result);
   }
   return NotFound();
}

[HttpGet, Route("Logout")]
public async Task<IActionResult> Logout() {
   if (_signInManager.IsSignedIn(User)) await _signInManager.SignOutAsync();
   return Ok();
}

Les méthodes se comprennent d’elles mêmes.

L’appel à la méthode de login « https://localhost:44393/api/Auth/Login?login=Clemox&password=P@swword123 &raquo; renvoie le résultat suivant.

{
    "succeeded": true,
    "isLockedOut": false,
    "isNotAllowed": false,
    "requiresTwoFactor": false
}

Nous pouvons voir dans les cookies qu’un cookie « .AspNetCore.Identity.Application » est apparu pour nous identifier auprès de l’API.

3.4. Récupérer les informations de l’utilisateur connecté

Cette dernière méthode permet de récupérer l’utilisateur connecté après l’appel du « login ».

[HttpGet, Route("GetMe")]
public async Task<IActionResult> GetMe() {
   return Ok(await _userManager.GetUserAsync(User));
}

Le résultat de l’appel de l’URL « https://localhost:44393/api/Auth/GetMe &raquo; renvoie les information de l’utilisateur « User ». Pour rappel, « User » est l’objet représentant l’utilisateur connecté en ASP.Net.

{
    "id": "1d16c0ab-c8e7-478d-9d0a-b450c10cd0c3",
    "userName": "Clemox",
    "normalizedUserName": "CLEMOX",
    "email": null,
    "normalizedEmail": null,
    "emailConfirmed": false,
    "passwordHash": "...",
    "securityStamp": "...",
    "concurrencyStamp": "ccba8377-7af0-4099-9e6b-acba3e7f3b34",
    "phoneNumber": null,
    "phoneNumberConfirmed": false,
    "twoFactorEnabled": false,
    "lockoutEnd": null,
    "lockoutEnabled": true,
    "accessFailedCount": 0
}

4. Ajoutons des authorisations et un rôle d’administrateur

Pour finir, nous allons ajouter la notion d’autorisation qui est essentielle dans toute application. En effet, tout utilisateur ne peut pas faire ce qu’il veut ! Ainsi, nous ajouterons un rôle Administrateur permettant de rechercher un utilisateur par son nom.

4.1. Activation de l’autorisation

C’est assez simple, il suffit de déclarer le service et de l’activer.

public void ConfigureServices(IServiceCollection services) {
   ...
   services.AddAuthorization();
   services.AddControllers();
}

public void Configure(IApplicationBuilder app, IWebHostEnvironment env) {
   ...
   app.UseRouting();
   app.UseAuthentication();
   app.UseAuthorization();
   app.UseEndpoints(endpoints => endpoints.MapControllers());
}

Attention a bien mettre le « app.UseAuthorization() » après le « UseRouting », sinon ca ne fonctionnera pas. Il y a une logique dans l’ordre d’activation des services qui fonctionnent en pipe. En effet, ils sont exécutés les uns après les autres pour chaque requête, et pour savoir si un contrôleur ou une méthode nécessite de vérifier l’autorisation, il faut d’abord qu’il soit trouvé via le routage.

4.2. Ajout de la méthode de recherche d’un utilisateur

Nous créons une méthode permettant de rechercher un utilisateur par son login, elle nécessite que l’utilisateur soit autorisé. Sans préciser de rôle, cela revient à dire qu’il est simplement authentifié.

[Authorize]
[HttpGet, Route("Search")]
public async Task<IActionResult> Search(string login) {
   return Ok(await _userManager.FindByNameAsync(login));
}

Pour tester la méthode, commençons par faire un logout.
=> Le résultat de l’appel « https://localhost:44393/api/Auth/Search?login=clemox &raquo; est un 404.
La méthode est invisible si nous ne sommes pas connectés.

Appelons le login, et rappelons la méthode de recherche.
=> L’utilisateur est bien retourné comme avec le « GetMe ».

4.3. Ajout d’un rôle administrateur

Toute personne connectée ne devrait pas pouvoir rechercher les utilisateurs, nous allons créer un rôle « Admin » spécifique pour la méthode de recherche.

Nous ajoutons d’abord le service de gestion des rôles « RoleManager » qui prend en argument générique le type de la classe des rôles.

private UserManager<IdentityUser> _userManager;
private SignInManager<IdentityUser> _signInManager;
private RoleManager<IdentityRole> _roleManager;
public AuthController(UserManager<IdentityUser> userManager, SignInManager<IdentityUser> signInManager, RoleManager<IdentityRole> roleManager) {
   _userManager = userManager;
   _signInManager = signInManager;
   _roleManager = roleManager;
}

Créons ensuite une méthode pour ajouter le rôle à un utilisateur.

    [HttpGet, Route("SetRole")]
    public async Task<IActionResult> SetRole(string login)
    {
        var role = await _roleManager.FindByNameAsync("Admin");
        if (role == null) await _roleManager.CreateAsync(new IdentityRole { Name = "Admin" });

        var model = await _userManager.FindByNameAsync(login);
        if (model != null) await _userManager.AddToRolesAsync(model, new[] { "Admin" });

        return Ok();
    }

La méthode se comprend d’elle même. Bien sûr, elle n’est pas protégée, il ne faudrait pas la laisser en production 😉
La méthode « https://localhost:44393/api/Auth/SetRole?login=Clemox &raquo; permet d’ajouter le rôle « Admin » à l’utilisateur « Clemox ».

Nous pouvons modifier la méthode « Search » pour indiquer qu’il faut que l’utilisateur connecté est le rôle « Admin ».

[Authorize(Roles = "Admin")]
[HttpGet, Route("Search")]
public async Task<IActionResult> Search(string login) {
   return Ok(await _userManager.FindByNameAsync(login));
}

Il faut refaire un appel à l’URl de login pour mettre à jour le cookie d’authentification. Ensuite, l’appel à la méthode « Search » fonctionne uniquement pour l’utilisateur « Clemox » !

La magie du .Net a à nouveau opéré 😀

5. Pour aller plus loin dans la configuration

La librairie ASP.Net Identitiy fournie plusieurs possibilités de configuration par défaut vis-à-vis de la gestion du mot de passe, par exemple. Vous pouvez configurer cela dans le fichier « Startup.cs », dans la méthode de déclartion des services.

services.AddIdentity<IdentityUser, IdentityRole>(options =>
            {
                options.Password.RequireDigit = true;
                options.Password.RequiredUniqueChars = 5;
            })

Egalement, vous pouvez configurer le cookie généré via la méthode spéciale « ConfigurationApplicationCookie ». Notamment, il est possible de changer le nom du cookie ou la page de direction.

services.ConfigureApplicationCookie(options => {
   options.Cookie.Name = "IdentityServer";
   options.LoginPath = "/Login";
});

En regardant plus en détail dans ces méthodes, ainsi que les services « UserManager, « SignInManager » et « RoleManager », vous pourrez gérer facilement vos utilisateurs et leur rôle !

Enfin, la librairie peut aussi gérer des interactions avec des systèmes externes : Google, Facebook et Microsoft.

Sortie d’Angular 10

La version 10 du Framework de développement Web est sortie.
Pas de grosse nouveautés au programme, il s’agit surtout d’un ensemble de correction de problèmes, d’optimisation et de ménage des éléments dépréciés.

La liste complète des nouveautés sur le blog Angular.

Comme toujours, la mise à jour se fait via le code :

ng update @angular/cli @angular/core
npm install @angular/cli -g