Sortie d’Angular 13 et .NET 6 !

Double sortie pour ce mois de novembre avec Angular 13 et .NET 6 !

Nouveautés d’Angular 13

Le post officiel décrit les grandes nouveautés.

  • La mise en place d’Ivy se continue
  • Fin du support de IE11 (j’ai l’impression que c’est dans chaque version depuis la 10)
  • Amélioration d’Angular CLI avec un cache de build qui améliore le temps de compilation

Du point de vue d’un développeur lambda, il s’agit de petites nouveautés qui améliorent la vie 🙂

A noter que comme chaque nouvelle sortie du Framework, une suppression du dossier « node_modules » et du fichier « package.lock.json » a été nécessaire.

Nouveautés de .NET 6

Le post officiel décrit les grandes nouveautés.

  • Le framework est LTS (3 ans de support)
  • Grosse amélioration des performances sur tous les aspects (I/O, mémoire, temps d’exécution …)
  • C# 10

Microsoft continue à refaire le Framework .NET en version Open Source / multi-plaforme et plus rapide.
Petit problème pour le moment, le SDK existe uniquement pour Visual Studio 2022 (preview), il faudra attendre un peu pour avoir la compatibilité avec VS 2019.

Développer en HTTPS avec Angular et un certificat SSL valide sous Chrome

Le développement en HTTPS (certificat SSL) permet d’être iso-production. Cependant, il est souvent compliqué d’avoir un certificat qui fonctionne avec les navigateurs, surtout depuis que Chrome est devenu difficile sur le sujet. Cet article vous montre comment configurer un certificat SSL valide sous Chrome pour travailler en HTTPS avec Angular, le tout en 5 minutes !

Running Angular CLI over HTTPS with a Trusted Certificate | by Ruben  Vermeulen | Medium

1. Générer un certificat de développement

J’ai trouvé 2 solutions possibles :
– Soit vous avez créé un projet sous Visual Studio 2019 avec le SSL activé, dans ce cas Visual Studio 2019 se charge de la création du certificat
– Soit vous pouvez le générer manuellement via la commande suivante : (pas besoin de se prendre la tête avec openssl !)

Nécessite le SDK .NET 5.0
dotnet dev-certs https --trust  

2. Récupérer le certificat de développement .NET

Il nous faut d’avoir récupérer le certificat généré par le .NET. Ma solution est simple, mais fonctionne uniquement avec Windows … il doit y avoir un moyen similaire sous Mac / Linux.

  1. Ouvrez une console de management [Windows+R > mmc]
  2. Menu « Fichier > Ajouter / Supprimer un composants enfichable… »
  3. Sélectionnez « Certificats », cliquez sur « Ajoutez », sélectionnez « Un compte d’ordinateur », cliquez sur « Suivant > Terminer »
  4. Cliquer sur OK pour revenir à la console avec la liste des certificats

Voilà notre certificat « IIS Express Development Certificate » ! Nous noteras qu’il est valable 5 ans, ca laisse du temps 😉

  1. Double cliquez sur le certificat pour l’ouvrir
  2. Allez dans « Details », cliquez sur « Copiez dans un fichier … »
  3. Cliquez sur « Suivant » et sélectionnez « Oui, exporter la clé privée« , puis cliquez sur « Suivant > Suivant »
  4. Cochez « Mot de passe » et saisissez-en un, « localhost » par exemple, puis « Suivant »
  5. Entrez le chemin du fichier à sauvegarder, par exemple « C:\localhost.pfx », puis cliquez sur « Suivant » et enfin « Terminer »

=> L’archive « C:\localhost.pfx » est généré !

3. Extraire le certificat et sa clé

Malheureusement, Angular ne sait pas prendre en entrée un fichier PFX. Il faut extraire le certificat et sa clé manuellement. Pour cette partie, OpenSSL est nécessaire, mais pas besoin d’une autre installation 🙂
– Commencez par récupérer les binaire sur le site de SourceForge.
– Dézippez l’archive dans « C:\openssl » : attention, ce point est important [l’exécutable va chercher un fichier dans ce chemin]

Ouvrir une commande Windows, placez vous dans « C:\openssl\bin » et exécutez les commandes suivantes :

openssl pkcs12 -in localhost.pfx -nocerts -out localhost.key -nodes
openssl pkcs12 -in localhost.pfx -clcerts -nokeys -out localhost.crt

Et c’est fini, vous pouvez récupérer les fichier « localhost.key » et « localhost.crt » et supprimer OpenSSL si vous voulez.

4. Lancer Angular en HTTPS

Ouvrez votre projet Angular, et éditer le fichier « Angular.json ».
Dans la partie « server », ajouter les 3 options concernant le SSL :

"serve": {
    "builder": "@angular-devkit/build-angular:dev-server",
    "options": {
        "browserTarget": "japon-suki-core-website:build",
        "proxyConfig": "proxy-config.json",
        "ssl": true,
        "sslCert": "C:\\openssl\\bin\\localhost.crt",
        "sslKey": "C:\\openssl\\bin\\localhost.key"
    },
    ...
}

Et c’est terminé, lancez votre projet Angular classiquement « ng serve » pour accéder au site en HTTPS : https://localhost:4200

Cas particulier pour les applications utilisant le proxy Angular avec l’authentification Windows

L’article sur l’utilisation du proxy Angular a été mis à jour.

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.

Nouveautés de la fin d’année : sortie de .NET 5, Angular 11 et Microsoft Q/A

Cette fin d’année est assez riche en nouveautés puisque nous pouvons noter 2 sorties majeures, et Microsoft met à disposition un nouveau site Web d’entraide autour du.NET.

.NET 5

Fini le Framework .NET et .NET Core, aujourd’hui, nous ne parlons plus que du .NET 5. En réalité, il s’agit de la continuité du .NET Core 3 qui a directement été nommé « .NET 5 » pour bien indiquer qu’il n’y aura plus d’évolution du côté du Framework .NET.

La liste complète des nouveautés est disponible sur le site de Microsoft.

Pour rappel, le .NET 5 est la plateforme unifiée et open-source de Microsoft qui doit répondre à tous les besoins d’applications : Web, client lourd, jeux videos, IA, objets connectés …

A noter également que cette version .NET 5 n’est pas une LTS (long term support). Une nouvelle version majeure du .NET sortira chaque année, et seule 1 sur 2 sera en LTS. La prochaine LTS sera le .NET 6.

Angular 11

La dernière version d’Angular est également sortie en novembre. Honnêtement, pas de grosses nouveautés sur un Framework qui commence à être mature.

A l’utilisation, nous pouvons noter des améliorations notable sur la rapide du build et sur les informations présentées.

Microsoft Q/A

Enfin, Microsoft lance son site de question / réponse dédié au .NET. Il va falloir qu’ils mettent les bouchées doubles avec des supports par leur développeur pour que la plateforme grossisse vite ! En face, il y a Stack Overflow qui est déjà très complet.

ASP.Net Core – authentification et autorisation – 1 : les bases

La gestion de l’authentification et des autorisations dans ASP.Net Core évolue pas mal par rapport à l’ASP.Net classique. Beaucoup de personnes ont également du mal à appréhender ses concepts en .NET. Cet article vise est une introduction à la gestion de l’authentification et des autorisation en .ASP.Net Core 3.1.

1 – Les concepts
2 – Code : Authentification par Cookie
3 – Code : Ajoutons de l’autorisation
4 – Code : Ajoutons des rôles
5 – Conclusion

1 – Les concepts

Authentification et autorisation

Ces deux notions simples sont étroitement liées mais bien différentes.

  • L’authentification est le fait de savoir qui est la personne qui accède au site Web.
  • L’autorisation de savoir si une personne peut accéder à une ressource protégées (page, api …). Cela sous-entend forcément que la personne a d’abord été authentifiée.

En ASP.Net Core, ces deux principes sont bien présents distinctement.

Les claims

Ce mot peut être littéralement traduit « revendication », mais je traduirais plutôt le concept par « Information ». Pour rester logique, je vais conserver le mot « claim ». Il fait souvent peur aux développeurs car ils ne comprennent pas bien le concept. J’avoue ne pas bien comprendre la notion sous-jacente car il s’agit de simples propriétés liées à l’utilisateur.

En .Net, toute personne étant authentifiée se voir attribuer :
– Un claim principal content une liste d’identité pour l’utilisateur connecté.
– Chaque identité ayant une liste d’information sur l’utilisateur. Plusieurs sont définis par défaut dans la documentation ClaimTypes, mais vous pouvez déclarer tout ce que vous voulez.

Autre dit, lorsqu’un utilisateur est authentifié sur un site Web, il lui ait associé des claims. Par exemple, s’il s’agit d’une authentification Windows par Active Directory, les claims seront des propriétés de l’active directory. Il est possible de les compléter via l’interface « IClaimsTransformation » mais c’est un autre sujet.

Les schemes

Un « scheme » se traduit littéralement par schéma. Cela correspond au schéma d’authentification qui inclue un service d’authentification et sa configuration. Autrement dit, cela définie le moyen par lequel l’utilisateur va s’authentifier sur le site Web.

Bien qu’il soit possible de créer son schéma de toute pièce, ASP.Net Core fournie déjà un ensemble de schémas prédéfinis. Je n’ai pas trouvé de documentation faisant une liste exhaustive des schéma. Le tableau suivant liste ceux que je connais.

CookieAuthenticationDefaultsUtilisation d’un simple cookie
JwtBearerDefaultsUtilisation d’un token JWT (Json Web Token)
OpenIdConnectDefaults OpenId est une surcouche utilisant protocole OAuth2 avec un token JWT qui permet la fédération d’identité entre plusieurs sites différents.
FacebookDefaults
GoogleDefaults
MicrosoftAccountDefaults
TwitterDefaults
Utilisation de service tiers pour authentifier un utilisateur.
IISDefaultsUtilisation du serveur IIS (par Active Directory) avec session gérée dans le processus IIS (In-Proc)
IISServerDefaultsUtilisateur du serveur IIS (par Active Directory) avec session gérée par un processus externe (Out-Proc via SessionStateManager)

Ce concept est donc important puisque c’est bien le « scheme » qui va définir la façon dont les utilisateur vont pouvoir s’authentifier dans ASP.Net Core.

2 – Code : authentification par Cookie

Dans cet exemple, j’ai créé un site Web Api simple sans authentification. Le test se fait avec le logiciel gratuit Postman.

Qu’allons nous faire ?

Le but est d’avoir un contrôleur Web API qui permette à un utilisateur connecté de s’authentifier. Par la suite, nous vérifierons que cette personne est autorisée à accéder à un second contrôleur Web API protégé.

Création de l’API d’authentification

a – Le code du contrôleur

La première étape que nous allons faire est la création d’un contrôleur permettant d’enregistrer une personne. Pour cela, il est nécessaire de lui créer une identité avec des claims. Le tout servant à s’authentifier avec un cookie.

Nous avons un contrôleur « LoginController » avec une méthode « Login » permettant d’authentifier l’utilisateur, et une méthode « Get » récupérant l’utilisateur authentifié.

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Mvc;
using System.Collections.Generic;
using System.Security.Claims;
namespace clemovernet.web.Controllers
{
    [Route("api/[controller]"), ApiController]
    public class LoginController : ControllerBase {

        [HttpGet, Route("Sign")]
        public IActionResult Sign() {
            // création de 3 claims inclus dans une identité
            var claims = new List<Claim> { 
                new Claim(ClaimTypes.Name, "Mister T"), 
                new Claim(ClaimTypes.Email, "mister.t@mail.com"), 
                new Claim("Test_claim", "Super") 
            };
            var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); 
            var principal = new ClaimsPrincipal(identity);

            // Authentification de l'utilisateur
            HttpContext.SignInAsync(principal); 

            return Ok();
        }

        [HttpGet, Route("User")]
        public IActionResult Get() {
            var obj = new { 
                IsAuthenticated = User.Identity.IsAuthenticated, 
                UserName = User.Identity.Name,
                Claims = User.Identities.FirstOrDefault().Claims
.Select(x => new { x.Type, x.Value })
            }; 
            return Ok(obj);
        }

        [HttpGet, Route("Logout")]
        public IActionResult Logout() {
            HttpContext.SignOutAsync();
            return Ok();
        }
    }

}

L’idée étant d’avoir une notion simple du code, il n’y a pas de notion de login / mot de passe. Bien sûr, vous pourriez vérifier ces informations avant d’appeler la méthode signature « SignInAsync ». Une fois qu’un utilisateur accèdera à la page, nous pourrons consulter son identifier via « HttpContext.User ».

b – Déclaration et configuration des services

ASP.Net Core fonctionne par un système d’injection de dépendance dont la configuration se fait dans le fichier « Startup.cs ».

Commençons par ajouter notre service d’authentification en ajouter la gestion des cookies. Le paramètre dans « AddAuthentication » sert à indiquer le schéma à utiliser par défaut, notamment lorsqu’il y en a plusieurs. Nous avons mis « AddCookie », nous pourrions gérer plusieurs types en plus (JWT par exemple).

public void ConfigureServices(IServiceCollection services) {
    services.AddControllers();
    services.AddAuthentication(
        CookieAuthenticationDefaults.AuthenticationScheme
     )
    .AddCookie();
}

Ensuite, nous pouvons configurer le pipeline d’utilisation des services déclarés. L’ordre de déclaration est très important.

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

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

c – Tests

Lançons le projet Web API en debug et ouvrons PostMan.

1er appel : récupérons l’utilisateur (GET http://localhost:59537/api/Login/User)
Le résultat est assez clair, l’utilisateur n’a aucune information et n’est pas authentifié !

{
    "isAuthenticated": false,
    "userName": null,
    "claims": {}
}

2nd appel : authentifions l’utilisateur (GET http://localhost:59537/api/Login/Sign)
Le retour se fait sans erreur, et il y a un cookie qui est renvoyé.
Il s’appelle « .AspNetCore.Cookies » et contient une chaîne de caractère chiffrée liée à la machine. Son contenu est une description de l’identité de l’utilisateur.

3ème appel : récupérons l’utilisateur (GET http://localhost:59537/api/Login/User)
Cette fois-ci, le résultat est différent du 1er appel, nous avons nos informations !

{
    "isAuthenticated": true,
    "userName": "Mister T",
    "claims": {
        {
            "type": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
            "value": "Mister T"
        }, {
            "type": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
            "value": "mister.t@mail.com"
        }, {
            "type": "Test_claim",
            "value": "Super"
        },
    }
}

L’utilisateur est bien authentifié avec sa liste de claims. Nous noterons que le claim « name » est utilisé dans « User.Identity.UserName ».

4ème appel : déconnexion de l’utilisateur (GET http://localhost:59537/api/Login/Logout)
Nous appelons la méthode de déconnexion, le cookie ASP.Net Core disparait.
Si nous rappelons l’API « User », nous revenons au résultat du 1er appel.

Nous avons donc vu comment authentifier simplement un utilisateur via un cookie sur une Web API. Cette technique est simple et ne permet pas, par exemple, de fédération avec d’autres sites / API puisque le cookie est lié à la machine qui l’a généré.

3 – Code : Ajoutons de l’autorisation

Maintenant que nous savons qui se connecter à notre site, nous voudrions pouvoir définir s’il a le droit ou non d’accéder à certains contrôleur ou méthodes. Pour faire cela, il existe l’attribut « Authorize » qui se déclare sur des classes de contrôleur ou sur des méthodes de contrôleur.

Si un contrôleur / méthode a l’attribut « Authorize », alors l’utilisateur doit être authentifié. Dans le cas d’un schéma d’authentification par cookie, si l’utilisateur n’est pas authentifié, il est redirigé vers la page de login.

De plus, pour information, si un contrôleur a l’attribut « Authorize », il existe l’attribut de méthode « AllowAnonymous » qui a l’effet inverse. Il autorise une personne non authentifiée à accéder à la méthode.

Gestion d’une autorisation simple

a – Le code du contrôleur

Nous allons modifier les méthodes « Get » pour ajouter l’attribut « Authorize ».
Pour la méthode « Logout », nous allons tester si l’utilisateur est autentifié.

[HttpGet, Route("User"), Authorize]
public IActionResult Get() { ... }

[HttpGet, Route("Logout")] 
public IActionResult Logout() { 
    if (User.Identity.IsAuthenticated) { 
        HttpContext.SignOutAsync(); 
    } else {
        return Unauthorized(); 
    }
    return Ok(); 
}

b – Déclaration et configuration des services

Comme décrit dans l’introduction, si l’utilisateur n’est pas authentifié, alors il est redirigé vers la page d’authentification. Cette page est configuré dans la définition du service.

De plus, comme nous utilisons l’authentification, il faut ajouter cette notion dans le pipeline d’utilisation des services. Elle doit obligatoirement être déclarée après la notion d’authentification (restons logique ;))

public void ConfigureServices(IServiceCollection services) {
    services.AddControllers(); 
    services.AddAuthentication(
       CookieAuthenticationDefaults.AuthenticationScheme) 
            .AddCookie(opt => { opt.LoginPath = "/api/Login/Sign"; });
}

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

    app.UseAuthentication();
    app.UseAuthorization();

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

C’est la propriété « LoginPath » qui permet de définir la page de login. Dans l’exemple, nous renvoyons directement sur la méthode authentifiant l’utilisateur. Vous pouvez consulter la liste des options existantes pour le schéma d’authentification par cookie.

c – Tests

Le test commence avec un utilisateur qui n’est pas authentifié sur le site.

1er appel : récupérons l’utilisateur (GET http://localhost:59537/api/Login/User)
Lors de cet appel, l’attribut « Authorize » teste si la propriété « User.isAuthenticated » est vrai. Comme ce n’est pas le cas, la requête HTTP est redirigée vers l’API d’authentification « api/Login/Sign ». En résultat, le cookie d’authentification est créé.

2nd appel : récupérons l’utilisateur (GET http://localhost:59537/api/Login/User)
Pour cet appel, nous sommes bien authentifié, alors la réponse contient le détail de l’utilisateur.

3ème appel : déconnexion de l’utilisateur(GET http://localhost:59537/api/Login/Logout)
Le cookie d’authentification disparait.

3ème appel : rappel de la déconnexion de l’utilisateur(GET http://localhost:59537/api/Login/Logout)*
Cette fois-ci, l’utilisateur n’est plus authentifié, alors nous recevons un retour 401.

{
    "type": "https://tools.ietf.org/html/rfc7235#section-3.1",
    "title": "Unauthorized",
    "status": 401,
    "traceId": "|386aedd8-441380e7d2093549."
}

Tout fonctionne correctement, l’API protégée est accédée uniquement lorsque l’utilisateur est authentifié. Nous allons voir un dernier point qui consiste à autoriser l’accès à une API selon le rôle de l’utilisateur.

4 – Code : Ajoutons des rôles

Le rôle d’un utilisateur est défini par un claim particulier. il s’agit donc d’une simple propriété de l’utilisateur qui sera interprété par l’ASP.Net Core.

Gestion d’une rôle administrateur

a – Le code du contrôleur

Comme décrit dans l’introduction, il faut ajouter un nouveau claim « rôle » dans la construction de l’identité de l’utilisateur. Pour cela, c’est très simple, il faut suffit de compléter la méthode « Sign » :

var claims = new List<Claim> {
    new Claim(ClaimTypes.Name, "Mister T"), 
    new Claim(ClaimTypes.Email, "mister.t@mail.com"), 
    new Claim("Test_claim", "Super"), 
    new Claim(ClaimTypes.Role, "Admin"), 
    new Claim(ClaimTypes.Role, "Member") 
};

Dans l’exemple, j’ai ajouté 2 rôles à l’utilisateur : « Admin » et « Member ».

Pour vérifier si un utilisateur a bien un rôle lors de l’appel d’un contrôleur ou d’une méthode, il faut ajouter ce rôle en paramètre de l’attribut « Authorize ». Modifions donc la méthode « Get » dans ce sens.

[HttpGet, Route("User"), Authorize(Roles = "Admin")]
public IActionResult Get() { /* ... */ }

A noter 2 points importants :
– Si plusieurs rôles peuvent accéder à la ressource, il faut les séparer par une virgule « Admin,Member »
– Si un utilisateur doit avoir obligatoirement plusieurs rôles, il faut dupliquer l’attribut « Authorize »

Enfin, ajoutons une nouvelle méthode « AccessDenied » qui sera appelée lorsqu’un utilisateur n’a pas l’autorisation d’accéder à une méthode protégée par un rôle. La route est configurable via la propriété « AccessDeniedPath » du service de gestion des cookie.

[HttpGet, Route("AccessDenied")]
public IActionResult AccessDenied()
{
    return Ok("Accès refusé");
}

Je vous invite à consulter la documentation officielle pour gérer des notions plus complexes autour des rôles comme les « Policies ».

b – Déclaration de configuration des services

Nous avons simplement à compléter la propriété du chemin d’accès refusé utilisée par le service d’authentification par cookie avec la nouvelle méthode « AccessDenied ».

public void ConfigureServices(IServiceCollection services){
    services.AddControllers(); 
    services.AddAuthentication(
                  CookieAuthenticationDefaults.AuthenticationScheme)
            .AddCookie(opt => {
                    opt.LoginPath = "/api/Login/Sign";
                    opt.AccessDeniedPath = "/api/Login/AccessDenied";  
    });
}

b – Tests

Nous pouvons tester que notre utilisateur « Admin » peut accéder à la méthode « Get ».
Nous partons du principe que l’utilisateur n’est pas authentifié.

1er appel : authentifions l’utilisateur (GET http://localhost:59537/api/Login/Sign)
En résultat, le cookie d’authentification ASP.Net Core est bien créé.

2nd appel : récupérons l’utilisateur (GET http://localhost:59537/api/Login/User)
L’appel fonctionne bien et nous retrouvons toutes les informations de l’utilisateur.

c – Modifions le rôle de l’API

Pour vérifier que la gestion par rôle fonctionne bien, nous pouvons modifier l’attribut « Authorize » de la méthode « Get » pour changer le rôle.

[HttpGet, Route("User"), Authorize(Roles = "SuperAdmin")]
public IActionResult Get() { /* ... */ }

d – Tests 2

Nous partons du principe que l’utilisateur est déjà authentifié.

2nd appel : récupérons l’utilisateur (GET http://localhost:59537/api/Login/User)
La magie opère et nous renvoie le texte « Accès refusé ».

5 – Conclusion

Ce tutoriel présente vraiment les bases à connaitre sur l’authentification et l’autorisation des utilisateurs avec l’ASP.Net Core. Tous les autres systèmes d’authentification se basent sur les mêmes principes d’identité, de claim et de rôle.

Par exemple, l’attribut « Authorize » sera toujours l’élément vous permettant de gérer l’autorisation d’une personne à accéder à une méthode. Il peut être étendu via un héritage pour ajouter des propriétés personnalisées.

La seule particularité que nous avons vu est que les informations de l’utilisateur sont persistées dans un cookie d’authentification, alors que les autres systèmes utiliseront d’autres moyens (token json, active directory …).

Dans de prochains tutoriels, nous verrons la couche « ASP.Net Core Identity » qui est un middleware servant à gérer la persistance des utilisateurs. Cela évite d’avoir un créer son propre système de gestion des utilisateurs.

Package NPM de téléchargement de fichier pour Angular

Il est souvent difficile de télécharger un fichier depuis une URL ou un BLOB avec Angular. Il existe plusieurs librairies dont FileSaver.JS qui permettent cela.

Elles sont compatibles avec Angular mais n’ont pas été écrites pour Angular. Cela fait une différence car le compilateur a plus de mal à optimiser les bundles. J’ai donc décidé de créer mon propre projet Angular de téléchargement de fichier, et de le partager avec la communauté.

Le code source est entièrement sous mon GitHub, et le package est téléchargeable sous NPM. L’utilisation est très simple et est compatible Chromium, Firefox et IE11.

Installation du package

npm i @clemox/ngx-file-saver

Déclaration du service dans un composant

import { NgxFileSaverService } from '@clemox/ngx-file-saver';

constructor(
   private fileSaver: NgxFileSaverService
) { }

Appel pour télécharger un fichier depuis une URL

this.fileSaver.saveUrl(url, 'Test.docx');

Appel pour télécharger un blob

const blob = new Blob(['Test de blob'], { type: 'text/plain' });
this.fileSaver.saveBlob(blob, 'Test.txt');

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

Angular : gérer un fichier de configuration

1. Le problème

En développant des applications Angular, je me suis confronté à un problème :

Comment paramétrer mon application selon l’environnement ?

La réponse du Framework est bien entendu le fichier « environment.ts » avec son « environment.prod.ts » qui gère les variables selon le mode de compilation.
Ce fonctionnement n’est vraiment pas pratique. En effet, les éléments de configuration se trouvent dans un fichier TS qui est compilé et donc non modifiable.

Dans une bonne intégration, le package compilé ne change pas et la configuration est faite selon l’environnement. Hors, à minima, l’URL de la Web API utilisée par le site Web Angular change selon l’environnement. De même, l’application n’a pas à être redéployée parce qu’un paramètre est changé.

2. Conception de la solution

J’ai du développer un système particulier permettant de gérer la configuration du site Web même après la compilation de l’application.

L’idée est la suivante :

  • Angular utilise toujours le fichier « environment.ts » pour accéder aux paramètres dans le développement.
  • Un fichier JSON décrivant les paramètres du fichier « environment.ts » est chargé au démarrage du site Web Angular

Ainsi, il suffira de modifier le fichier JSON de configuration mais une prise à chaud dans l’application sans nécessité de recompilation.

3. Implémentation de la solution

Premièrement, il faut créer un service permettant de lire le fichier JSON et l’injecter dans l’objet « environment ».

Fichiers « environment.ts » et « environment.prod.ts »
Le fichier TS décrivant les paramètres de l’application et accessible lors du développement. Les propriétés ont des valeurs par défaut.

export const environment = {
    production: false,
    webApiUrl: '',
    propriete1: '',
    ...
};

Fichier « config.json »
Le fichier de configuration personnalisé de l’application qui reprend les propriétés de l’objet d’environnement. Il est créé au même niveau que le fichier « main.ts » du répertoire « src ».

{
    "webApiUrl": "http://.../",
    "propriete1": "123",
    ...
}

Fichier « configuration-helper.ts »
Le fichier de service est fait pour être mis dans une librairie de service.

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { map } from 'rxjs/operators';

@Injectable()
export class ConfigurationHelper {
    constructor(private http: HttpClient) { }

    // Charger les informations du fichier de configuration
    public load(environmentObject: object, path: string): Promise<any>
    {
        return this.http.get<any>(path).pipe(map(data => {

            if (data !== null && data !== undefined) {
                Object.keys(data).forEach(propertyData => {
                    if (Object.keys(environmentObject).findIndex(x => x === propertyData) !== -1) {
                        data[propertyData] = environmentObject[propertyData];
                    }
                });
            }
            return data;
        })).toPromise();
    }
}

La méthode est très générique. Elle recherche les propriétés du fichier JSON qui correspondent aux propriétés de l’objet de l’environnement, et lui affecte la valeur.

Fichier « app-module.ts »
Le fichier est le module d’entrée de l’application.

import { APP_INITIALIZER, NgModule } from '@angular/core';
import { environment } from 'src/environments/environment';
...

// Déclaration de la fonction d'initialisation de la configuration
export function initConfig(configService: ConfigurationHelper) {
    return () => configService.load(environment, 'config.json');
}

@NgModule({
    imports: [...],
    declarations: [...],
    // Déclaration de l'appel à la méthode "initConfig" au démarrage de l'application
    providers: [
        { 
provide: APP_INITIALIZER, useFactory: initConfig, deps: [ConfigurationHelper], multi: true },
    ],
    bootstrap: [AppComponent]
})
export class AppModule { }

L’implémentation est en 2 parties :

  • 1 : la méthode d’appel de notre service en passant en paramètre l’objet de l’environnement et le lien vers le fichier « config.json ».
  • 2 : déclaration d’une factory appelant la méthode au démarrage de l’application

3. Tests

Il n’y a plus qu’à tester que le tout fonctionne. Il vous suffit de lancer l’application Angular via le « ng serve » ou la compiler pour la déployer sur un serveur.

Les valeurs du fichiers « config.json » seront prises à chaud. Il faut quand même un rafraichissement du navigateur 😉

Angular – Créer une liste de sélection complexe avec ng-template (list-view)

Angular permet de créer facilement des composants Web très puissant grâce à la gestion de template. C’est ce que nous allons utiliser pour créer une liste complexe.

1. Le besoin

En HTML, la gestion des listes est très basique et le rendu est (moche) non personnalisable. Le simple code suivant affiche une liste dont les éléments sont du simple texte non personnalisable.

<select>
    <option value="0"></option>
    <option value="1">Facebook</option>
    <option value="2">Google</option>
    <option value="3">Microsoft</option>
</select>
Liste sous Chrome

Le besoin est par exemple d’avoir une liste pour laquelle le logo de la société est affichée à gauche du nom, ou pouvoir avoir une présentation complexe.

2. La conception

Pour implémenter notre liste, nous allons créer un composant spécialisé qui va gérer le menu de la liste. Le développeur gérera lui-même le tag de l’input, le composant viendra s’accrocher dessus.

<input type="text" name="select" #monInput>
<list-view [field]="monInput" ...>
   ...
</list-view>

Cette solution est très simple à mettre en oeuvre et laisse le développeur libre d’ajouter du style ou des erreurs à l’input.

3. L’implémentation

Nous créons un composant classique Angular avec les fichiers :
– Le contrôleur : list-view.component.ts
– Le template : list-view.component.html
– Le style : list-view.component.less

3.1. Définition du contrôleur

Toutes les propriétés sont décrites en commentaire, mais rien de très compliqué 😉

Component({
    selector: 'list-view',
    templateUrl: 'list-view.component.html'
})
export class ListViewComponent implements OnInit {
    constructor() { }

    // le lien vers l'input
    @Input() field: HTMLInputElement; 
    // liste des items de la liste
    @Input() items: any[];
    // nom de la propriété de l'item qui sera affichée dans l'input
    @Input() propertyDisplay = 'text';

    // valeur sélectionnée entrée / sortie
    @Input() ngModel: any;
    @Output() ngModelChange = new EventEmitter<any>();

    // Est-ce que le menu de la liste est visible ?
    public visible = false;

    //

    // Initialisation
    ngOnInit() { }
}

Ensuite, nous allons appliquer un style sur notre « input » pour qu’il ressemble le plus possible à un « select » et restreindre les possibilité d’écriture.

private stylyzeField(field: HTMLInputElement) {
    field.setAttribute('autocomplete', 'off'); 
    field.setAttribute('readonly', 'readonly');
    field.style.background = '#FFFFFF url(./arrow-bottom.png) no-repeat right';
    field.style.backgroundSize = '23px 25px';
    field.style.cursor = 'pointer';
    field.style.paddingRight = '28px';
    field.style.userSelect = 'none';

    // Empêche la sélection du texte dans l'input text
    field.addEventListener('select', () => {
        field.selectionStart = field.selectionEnd;
    });
}

ngOnInit() {
    if (this.field) this.stylyzeField(this.field);
}

En résumé, l’input est mis en lecture seule avec une image de flèche positionnée à droite. L’image utilisée est la suivante.

Enfin, nous allons gérer la visibilité du menu de la liste. L’événement « click » de l’input est accroché et on inverse la valeur de visibilité de menu de la liste.

ngOnInit() {
    if (this.field) {
        this.stylyzeField(this.field);
        this.field.addEventListener('click', () => {
            this.visible = !this.visible;
         });
    }
}

Nous ajoutons également la méthode permettant de sélectionner un élément dans la liste et de le renvoyer vers le composant parent. Egalement, nous forçons l’affichage du texte dans l’input..

public select(model: any) {
    this.ngModelChange.emit(model); // envoyer la sélection vers le parent
    this.field.value = model[this.propertyDisplay];
    this.visible = false; // fermeture du menu
}

3.2 Le template

Cette partie est plus complexe car elle intègre la notion de template personnalisé.
Voici le début de l’implémentation :

<div class="list-view" *ngIf="visible">
   <div *ngFor="let i of items" (click)="select(i)">
   </div>
</div>

Le HTML décrit notre menu dont l’affichage dépend du paramètre « visible ». Lui-même contient la liste des item générés par un classique « *ngFor ».
Au clic sur l’un des items, la méthode « select » est appelée.

Pour gérer notre template personnalisé, nous allons ajouter la déclaration suivante dans la partie contrôleur :

@ContentChild(TemplateRef, { static: false }) template;

Cette ligne permet déclarer une variable « template » et donne accès à l’élément déclaré dans le HTML du composant. Dans l’exemple ci-dessous, cela correspond à la balise « <ng-template> » qui est de type Angular « TemplateRef ».

<input type="text" name="select" #monInput>
<list-view [field]="monInput">
    <ng-template></ng-template>
</list-view>

Retournons maintenant dans le template de notre composant pour le compléter :

<div *ngFor="let i of items" (click)="select(i)">
    <ng-container *ngTemplateOutlet="template; context:{item: i}">
    </ng-container>
</div>

La nouvelle ligne « ng-container » utilise l’attribut « *ngTemplateOutlet » pour injecter le « template » définie dans la partie contrôleur. De plus, la propriété « context » permet de décrire un objet contextuel au template. Nous y déclarons une variable « item » à laquelle l’item courant est affecté.

3.3 Le style

La dernière partie concerne le style de notre menu qui est décrit dans un fichier LESS.

.list-view {
    background: #FFFFFF;
    color: #444444;
    box-shadow: 3px 0 3px 0 rgba(0,0,0,0.1), 0 2px 2px 0 rgba(0,0,0,0.15);
    max-height: 250px;
    overflow-y: auto;
    position: absolute;
    user-select: none;
    z-index: 9999;

    & > div {
        align-items: stretch;
        justify-content: stretch;
        border-bottom: 1px solid #AAAAAA;
        cursor: pointer;
        display: flex;
        flex-direction: row;
        font-size: 1rem;
        line-height: 29px;
        padding: 5px 10px;

        &:hover {
            background: #AAAAAA;
        }
        &:last-child {
            border-bottom: none;
        }
    }
}

4. L’utilisation

Nous avons enfin défini notre liste personnalisée, il ne reste plus qu’à l’utiliser 😀
Imaginons un composant Angular dans lequel nous avons défini une liste de sociétés avec leur icône. Le but est d’en sélectionnée une.

Déclaration dans le contrôleur
Nous déclarons la valeur qui sera sélectionnée, ainsi que la liste des éléments sélectionnables : un id, un texte et une icône (url externe).

public itemSelected: any;
public itemList = [
   { id: 1, text: 'Facebook', icon: 'https://upload.wikimedia.org/wikipedia/commons/1/16/Facebook-icon-1.png' },
   { id: 2, text: 'Google', icon: 'https://upload.wikimedia.org/wikipedia/commons/5/53/Google_%22G%22_Logo.svg' },
   { id: 3, text: 'Microsoft', icon: 'https://upload.wikimedia.org/wikipedia/commons/5/5f/Microsoft_Office_logo_%282019%E2%80%93present%29.svg' }
];

Déclaration dans le template
C’est la partie qui nous intéresse le plus car elle décrit notre template personnalisé.
Dans la balise « ng-template », nous créons une variable « oneItem » grâce à l’attribut « let-oneItem » à laquelle nous affectons « item » qui est la valeur définie dans le « context » de notre contrôleur (voir 3.2).

Le début de nom d’attribut « let- » est reconnu par Angular et permet de déclarer une variable locale. Nous pouvons ainsi utiliser la variable « oneItem » pour accéder à l’objet d’une société de la liste « itemList » dans le template « ng-template ».
Cela nous permet de donner une forme personnalisée à notre liste.

<input type="text" name="select" #monInput>
<list-view [field]="monInput" [items]="itemList" [(value)]="itemSelected">
    <ng-template let-oneItem="item">
        <div style="display: flex; align-items: center;">
           <img src="{{oneItem.icon}}" style="width: 25px; height: 25px; padding-right: 10px;" />
           <div>{{oneItem.text}}</div>
        </div>
    </ng-template>
</list-view>

Pour des raisons de simplicité, j’ai directement intégré le CSS dans le template, mais il est plus propre de le sortir dans un fichier CSS global à part.

Le résultat est beaucoup plus sympa que la version d’origine 🙂

5. Quelques améliorations

Cet exemple vous donne la base pour créer des template personnalisé avec répétition. Dans l’exemple de la liste, il manque quelques points pour la rendre plus propre.

Fermer la liste si l’on clique à côté du menu
Il faut ajouter le code suivant qui vérifie si l’utilisateur clique en dehors du menu.

<div #menuList>
    <div class="list-view" *ngIf="visible">
        <div *ngFor="let i of items" (click)="select(i)">
            <ng-container *ngTemplateOutlet="template; context:{item: i}"></ng-container>
        </div>
    </div>
</div>
@ViewChild('menuList', { read: ElementRef, static: true }) resultList: ElementRef;

@HostListener('document:click', ['$event'])
clickedOutside(event: Event) {
    const elt = event.target as Element;
    if (elt !== this.field) {
        if (this.menuList && this.menuList.nativeElement !== event.target 
           && !this.menuList.nativeElement.contains(elt)) {
            this.visible = false;
        }
    }
}

Sélectionner une valeur initiale
Il faut ajouter le code suivant qui sélectionne la valeur par défaut à l’initialisation.

// nom de la propriété de l'item qui sera affichée dans l'input
@Input() propertyId = 'id';
// sélectionner la 1ère valeur par défaut
@Input() selectFirstValue = true;

export class ListViewComponent implements OnInit, OnChanges {
...
    ngOnChanges() {
        this.initializeDefaultSelection();
    }

    private initializeDefaultSelection() {
        if (this.items && this.items.length > 0) {
            let item = null;

            if (this.value) {
                item = this.items.find(x => 
                       x[this.propertyId] === this.value[this.propertyId]);
            } else if (this.selectFirstValue) {
                item = this.items[0];
            }
            if (item !== null) this.select(item);
        }
    }
}

Il est encore possible d’aller plus loin :
– Remplacer l’input par l’affichage du template personnalisé
– Permettre de taper du texte dans l’input pour rechercher les éléments
– Faire un appel asynchrone pour récupérer une liste d’élément depuis un serveur
– Modifier la largeur du menu pour qu’il est la même que l’input
– …

6. Code source complet

Pour finir, voici le code source complet des 3 fichiers.

list-view.component.ts

import {
    Component,    ContentChild,    ElementRef,    EventEmitter,
    HostListener,    Input,    OnChanges,    OnInit,    Output,
    TemplateRef,    ViewChild,
} from '@angular/core';

@Component({
    selector: 'list-view',
    templateUrl: 'list-view.component.html',
    styleUrls: ['./list-view.component.less']
})
export class ListViewComponent implements OnInit, OnChanges {
    constructor() { }

    //// les entrées / sorties

    // le lien vers l'input
    @Input() field: HTMLInputElement;
    // liste des items de la liste
    @Input() items: any[];
    // nom de la propriété de l'item qui sera affichée dans l'input
    @Input() propertyId = 'id';
    // nom de la propriété de l'item qui sera affichée dans l'input
    @Input() propertyDisplay = 'text';
    // sélectionner la 1ère valeur par défaut
    @Input() selectFirstValue = true;

    // valeur sélectionnée entrée / sortie
    @Input() value: any;
    @Output() valueChange = new EventEmitter<any>();

    //

    // Est-ce que le menu de la liste est visible ?
    public visible = false;

    @ViewChild('menuList', { read: ElementRef, static: true }) menuList: ElementRef;
    @ContentChild(TemplateRef, { static: false }) template;

    //

    ngOnInit() {
        if (this.field) {
            this.stylyzeField(this.field);

            this.field.addEventListener('click', () => {
                this.visible = !this.visible;
            });
        }
    }

    ngOnChanges() {
        this.initializeDefaultSelection();
    }

    @HostListener('document:click', ['$event'])
    clickedOutside(event: Event) {
        const elt = event.target as Element;
        if (elt !== this.field) {
            if (this.menuList && this.menuList.nativeElement !== event.target && !this.menuList.nativeElement.contains(elt)) {
                this.visible = false;
            }
        }
    }

    public select(model: any) {
        this.valueChange.emit(model);
        this.field.value = model[this.propertyDisplay];
        this.visible = false;
    }

    //

    private stylyzeField(field: HTMLInputElement) {
        field.setAttribute('autocomplete', 'off');
        field.setAttribute('readonly', 'readonly');
        field.style.background = '#FFFFFF url(/Styles/images/arrow-bottom.png) no-repeat right';
        field.style.backgroundSize = '23px 25px';
        field.style.cursor = 'pointer';
        field.style.paddingRight = '28px';
        field.style.userSelect = 'none';

        field.addEventListener('select', () => {
            field.selectionStart = field.selectionEnd;
        });
    }

    private initializeDefaultSelection() {
        if (this.items && this.items.length > 0) {
            let item = null;

            if (this.value) {
                item = this.items.find(x => x[this.propertyId] === this.value[this.propertyId]);
            } else if (this.selectFirstValue) {
                item = this.items[0];
            }
            if (item !== null) this.select(item);
        }
    }

}

list-view.component.html

<div #menuList>
    <div class="list-view" *ngIf="visible">
        <div *ngFor="let i of items" (click)="select(i)">
            <ng-container *ngTemplateOutlet="template; context:{item: i}"></ng-container>
        </div>
    </div>
</div>

list-view.component.less

.list-view {
    background: #FFFFFF;
    color: #444444;
    box-shadow: 3px 0 3px 0 rgba(0,0,0,0.1), 0 2px 2px 0 rgba(0,0,0,0.15);
    max-height: 250px;
    overflow-y: auto;
    position: absolute;
    user-select: none;
    z-index: 9999;

    & > div {
        align-items: stretch;
        justify-content: stretch;
        border-bottom: 1px solid #AAAAAA;
        cursor: pointer;
        display: flex;
        flex-direction: row;
        font-size: 1rem;
        line-height: 29px;
        padding: 5px 10px;

        &:hover {
            background: #AAAAAA;
        }
        &:last-child {
            border-bottom: none;
        }
    }
}

Angular 9 est disponible !

Il s’agit d’une version majeure depuis les 3 dernières années. En effet, la version active IVY, le nouveau moteur de rendu du Framework.

Faire la mise à jour

Cette nouvelle version nécessite en prérequis d’installer NodeJS 10 au minimum.

La page de migration d’Angular décrit bien la procédure à suivre. Je conseille l’exécution des commandes suivantes :

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

Cela prend un peu de temps pour tout mettre à jour.

Quels sont les nouveautés ?

Les nouveautés de la version sont décrites sur le blog d’Angular. Les principales informations sont les suivantes. Je vous invite aussi à lire les API abandonnées.

IVY
Le nouveau moteur de rendu du Framework inclut plein d’améliorations sans pour autant modifier le code de l’application, notamment :

  • Les bundles sont plus petits
  • Meilleurs performances pendant les tests
  • L’objet « ng » permet d’appeler des méthodes du composant pour le debug
  • Meilleur affichage des erreurs

TypeScript 3.7
La dernière version du langage est utilisée par Angular 9.
Je vous invite à consulter les nouveautés des versions 3.6 et 3.7.

Le point que je trouve le plus marquant est l’opérateur de chaîne optionnel « ? ». Il est maintenant possible d’écrire :

let a = monObjet?.maPropriete1?.maPropriete2
let a = monTableau?.[1]
// => renvoi "undefined" si l'élément n'est pas accessible

Dans la pratique ?

Concrètement, j’ai fait la migration sur l’un de mes projets Angular 8.

Je n’ai eu aucune erreur de compilation après la migration du projet.
Suite à compilation, le bundle est passé de 3,15 Mo à 2,92 Mo, soit une diminution de 7%.