TestOIDCBlazorWASM (épisode 3) : IAM côté serveur et gestion des autorisations

Objectif

Comme annoncé lors du précédent épisode, le présent article va se concentrer sur la mise en place des API et, pour continuer la phase d’authentification sur le client déjà réalisée, propager l’identification vers le serveur et mettre en place un système d’autorisations de type RBAC et en mode "défense en profondeur".

Le principe du "Role-Based Access Control" est de donner des autorisations d’accès aux ressources en fonction du ou des rôles portés par un compte utilisateur ou par un groupe auxquels ce compte appartient. Ces rôles sont désignés par un identifiant et définissent des ensembles de droits sur des ressources (par exemple, lire des contrats, ou bien enregistrer des données de personnes, etc.). Dans l’épisode 1 de cette série d’articles, deux rôles ont été définis dans Keycloak, à savoir administrateur et lecteur. Comme leur nom l’indique, le premier a tous les droits et le second des droits sur les ressources, mais uniquement en consultation.

Par "défense en profondeur", on entend qu’un seul rideau de gestion des autorisations ne suffit pas et que des strates successives de vérification seront mises en place pour améliorer la sécurité d’accès. Ainsi, les droits seront validés pour accéder à un menu, puis sur le routage correspondant, mais également au niveau du composant Blazor qui s’affiche, ainsi qu’au moment de l’appel sur l’API. Ainsi, si une mesure de sécurité au niveau du client est mise en défaut, une autre pourra implémenter le mécanisme d’autorisation. Et dans tous les cas, le serveur implémentera bien sûr son propre rideau, car les autorisations ne peuvent pas être confiées au client : lorsqu’il les traite, c’est uniquement pour faciliter le parcours d’interface utilisateur en masquant des menus ou composants sur lesquels l’utilisateur ne pourrait de toute façon pas réaliser une action.

Propagation de l’identification

Pour varier les plaisirs par rapport au client (et pour simplifier la lecture de cet épisode assez long), les paramètres du fournisseur d’identité ne seront pas rajoutés dans le fichier appsettings.json du projet TestOIDCBlazorWASM.Server mais en dur. Le code à rajouter dans Program.cs est donc le suivant (il faut le placer avant l’appel à la fonction Build() sur la variable builder) :

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(o =>
{
     o.Authority = "http://localhost:8080/realms/Coucou/";
     o.Audience = "account";
});

Ce code permet de faire en sorte que le serveur utilise la même IAM que le client, en se basant pour récupérer l’identité en cours sur le JSON Web Token qui lui sera passé par l’intermédiaire du header HTTP Authorization, préfixé par le texte Bearer suivi d’un espace (comme cela est fait pour l’authentification de base, avec Basic suivi d’un espace). Pour qu’il compile, il est nécessaire de rajouter le package nommé Microsoft.AspNetCore.Authentication.JwtBearer et de positionner un using avec le même contenu en haut du code source.

Pour l’instant, nous ne rajoutons rien de plus à ce niveau du code. Attention toutefois à ne surtout pas se tromper sur l’orthographe de la valeur de l’audience, sous peine d’erreurs 401 incompréhensible, comme le rédacteur a pu s’en rendre compte…

Mise en place d’une API

Pour mettre en œuvre des autorisations, il faut des ressources à protéger : c’est ce que nous allons ensuite rajouter en mettant en place un contrôleur d’API qui exposera des données de personnes.

Pour cela, un contrôleur est ajouté sur le dossier Controllers du projet Server :

Le contrôleur sera de type API, et on démarre avec un contenu vide, et un nom de fichier (étape suivante de l’assistant) qui sera PersonnesController.cs :

L’implémentation dans un premier temps est quasi-vide, le but étant uniquement de tester dans cette étape du blog que les autorisations fonctionnent bien en lien avec l’identification du compte connecté :

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using TestOIDCBlazorWASM.Shared;

namespace TestOIDCBlazorWASM.Server.Controllers
{
     [Route("api/[controller]")]
     [ApiController]
     public class PersonnesController : ControllerBase
     {
         [HttpGet]
         public IActionResult LecturePersonnes(
             [FromQuery(Name = "$skip")] int skip = 0,
             [FromQuery(Name = "$top")] int top = 10
         )
         {
             return new OkObjectResult(new List<Personne>());
         }

        [HttpPost]
         public IActionResult CreationPersonne([FromBody] Personne personne)
         {
             throw new NotImplementedException("Fonction de création de personne à venir");
         }
     }
}

Pour que ce code compile, il faut ajouter une classe Personne, que nous mettrons dans le projet Shared (ne pas confondre avec le dossier du même nom dans le projet Client), et dont le contenu sera comme suit :

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace TestOIDCBlazorWASM.Shared
{
     public class Personne
     {
        public string? ObjectId { get; set; }

        public string? Patronyme { get; set; }

        public string? Prenom { get; set; }

        public string? URLFiche { get; set; }
     }
}

Consommation sur le client

Pour vérifier le fonctionnement, une page doit être ajoutée sur le projet Client. Le fichier correspondant sera positionné dans le répertoire Pages :

Le nom du fichier sera Personnes.razor, car il affichera une liste de personnes, et son contenu en première intention sera comme suit :

@page "/personnes"
@using Microsoft.AspNetCore.Authorization
@using TestOIDCBlazorWASM.Shared
@inject IHttpClientFactory HttpFactory

<PageTitle>Personnes</PageTitle>

<AuthorizeView>
     <Authorized>
         @if (personnes == null)
         {
             <p><em>Loading</em></p>
         }
         else
         {
             <table class="table">
                 <thead>
                     <tr>
                         <th>Prénom</th>
                         <th>Nom</th>
                         <th>Fiche</th>
                     </tr>
                 </thead>
                 <tbody>
                     @foreach (var p in personnes)
                     {
                         <tr>
                             <td>@p.Prenom</td>
                             <td>@p.Patronyme</td>
                             <td><a href="@p.urlFiche" target="_blank">Fiche</a></td>
                         </tr>
                     }
                 </tbody>
             </table>
         }
     </Authorized>
     <NotAuthorized>
         <h1>Personnes</h1>
         <div>Laccès à cette page nécessite dêtre authentifié</div>
     </NotAuthorized>
</AuthorizeView>

@code {
     private Personne[]? personnes;


    protected override async Task OnInitializedAsync()
     {
         var client = HttpFactory.CreateClient("WebAPI");
         personnes = await client.GetFromJsonAsync<Personne[]>("api/personnes");
     }
}

Il faudra rajouter une dépendance à la librairie Microsoft.Extensions.Http. pour que la référence à l’interface IHttpClientFactory puisse fonctionner. Ainsi, la fonction CreateClient en fin du code de la classe ci-dessous pourra fournir un client HTTP. La valeur du paramètre WebAPI doit se retrouver sur le code qui va être rajouté dans Program.cs, à savoir :

builder.Services
     .AddHttpClient("WebAPI", client => client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress))
     .AddHttpMessageHandler<BaseAddressAuthorizationMessageHandler>();

Ce code remplace la ligne suivante, qui injecte un HttpClient pour tous les usages, alors que nous souhaitons avoir une instance pour les appels autorisés et une autre pour les appels hors connexion :

builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });

Fonctionnement des autorisations sur le client

La balise <AuthorizeView> utilisée plus haut permet de faire en sorte que seuls des accès authentifiés sont réalisés sur la liste des personnes. Ainsi, si on lance l’application et qu’on navigue sur /personnes, l’affichage est le suivant :

Alors qu’une fois authentifié, l’accès est autorisé (même si l’API ne renvoie pour l’instant qu’une liste de personnes vide) :

Dans le premier cas, il y avait en plus une erreur dans l’exécution de l’application côté client :

Ceci est lié à l’implémentation très basique de l’appel à l’API, qui se réalise systématiquement à l’appel de la page, et sans gestion d’erreur pour l’instant. Mais nous allons remédier (partiellement) à ceci avec une approche un peu plus élégante.

Pré-autorisation côté client

En fait, ce que nous faisons sur le client n’est pas réellement de l’autorisation, car tant que le serveur n’est pas protégé (ce qui sera fait un peu plus loin), la donnée peut être accédée par un appel direct à l’API. Tout ce que nous faisons sur le client n’est qu’une sorte de pré-autorisation, bref une amélioration d’ergonomie pour ne pas rendre visibles des moyens d’accéder à la donnée. Il ne faut toutefois pas négliger cet aspect, pour l’utilisabilité de l’application avant tout, mais aussi parce que cela constitue toujours un rideau de défensif supplémentaire (aisément contournable par des sachants, mais tous les utilisateurs ne le sont pas).

Nous allons justement multiplier ces premiers rideaux de défense comme il se doit, en ajoutant au passage un menu pour accéder à cette page que nous avons pour l’instant appelé manuellement.

L’ajout d’un menu se fait en rajoutant le code suivant dans Shared/NavMenu.razor :

<div class="nav-item px-3″>
     <NavLink class="nav-link" href="personnes">
         <span class="oi oi-list-rich" aria-hidden="true"></span> Personnes
     </NavLink>
</div>

Mais pour montrer un premier rideau de blocage (absence du menu si pas d’authentification), nous allons l’entourer des mêmes balises que dans Personnes.razor. Le code est ainsi comme suit (extrait de la classe complète) :

<div class="@NavMenuCssClass" @onclick="ToggleNavMenu">
     <nav class="flex-column">
         <div class="nav-item px-3″>
             <NavLink class="nav-link" href="" Match="NavLinkMatch.All">
                 <span class="oi oi-home" aria-hidden="true"></span> Home
             </NavLink>
         </div>
         <div class="nav-item px-3″>
             <NavLink class="nav-link" href="counter">
                 <span class="oi oi-plus" aria-hidden="true"></span> Counter
             </NavLink>
         </div>
         <div class="nav-item px-3″>
             <NavLink class="nav-link" href="fetchdata">
                 <span class="oi oi-list-rich" aria-hidden="true"></span> Fetch data
             </NavLink>
         </div>
         <AuthorizeView>
             <Authorized>
                 <div class="nav-item px-3″>
                     <NavLink class="nav-link" href="personnes">
                         <span class="oi oi-list-rich" aria-hidden="true"></span> Personnes
                     </NavLink>
                 </div>
             </Authorized>
          </AuthorizeView>
     </nav>
</div>

Pour aller jusqu’au bout des rideaux de défense, la page Personnes.razor va également être protégée du point de vue du routage. Ainsi, même si quelqu’un ne voyant pas le menu continue à saisir l’URL, c’est l’accès à l’ensemble de la page qui sera interdit (et pas seulement le contenu différencié comme réalisé plus haut). Pour cela, en haut de ce fichier, nous ajoutons l’instruction ci-dessus :

@page "/personnes"
@attribute [Authorize]

Ces manipulations ont pour effet que le menu n’apparaît que lorsqu’on est connecté :

Et si un utilisateur tape directement dans l’URL l’adresse correspondante à la page, c’est désormais le mécanisme d’authentification de .NET qui prend la main et réagit de manière plus appropriée, en redirigeant vers l’IAM :

Application des autorisations sur le contrôleur

La partie client est réalisée, mais il nous reste tout de même à gérer l’essentiel, à savoir la sécurité des données elles-mêmes, en protégeant le contrôleur. En effet, pour l’instant, si on appelle directement l’API, le retour est visible (bien qu’il s’agisse pour l’instant d’une liste vide, car nous n’avons rien branché derrière le contrôleur) :

Pour sécuriser le contrôleur, rien de plus simple ; il suffit d’ajouter une balise [Authorize] comme sur le client pour signifier que cet ensemble de méthodes ne peut être accédé qu’avec un compte identifié :

[Authorize]
[Route("api/[controller]")]
[ApiController]
public class PersonnesController : ControllerBase

Le résultat est une interdiction, mais pas tellement comme attendu, car nous n’avons pas encore appliqué les mécanismes d’autorisation sur le projet lui-même :

Pour que le mécanisme fonctionne, il faut également indiquer dans le fichier Program.cs les éléments de code suivants pour activer l’authentification et les mécanismes d’autorisation :

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

A noter qu’il serait possible de protéger les méthodes une par une au lieu d’appliquer l’attribut à l’ensemble du contrôleur. Nous allons justement utiliser ce mode de fonctionnement un peu plus loin, lorsque nous ajusterons les droits en fonction des rôles de la personne connectée.

Gestion des rôles

Mais dans un premier temps, il est nécessaire de nous assurer que nous disposons bien de ces rôles. Ceci peut être vérifié en récupérant les tokens d’authentification :

En les mettant dans un décodeur comme https://jwt.io, on retrouve bien les claims pour les rôles tels qu’ils avaient été définis lorsque l’IAM a été mise en place dans un épisode principal de cette série (et en particulier pour notre application, dont le ClientID est TestOIDCBlazorWASM) :

Pour indiquer au projet Client où sont les claims pour les rôles, la ligne suivante doit être ajoutée dans l’appel à la fonction AddOidcAuthentication du fichier Program.cs :

options.UserOptions.RoleClaim = "resource_access.TestOIDCBlazorWASM.roles";

Un peu plus bas, la ligne suivante permet de brancher le mécanisme d’autorisation sur ces claims :

builder.Services.AddApiAuthorization().AddAccountClaimsPrincipalFactory<RolesClaimsPrincipalFactory>();

Pour que cette ligne compile, la classe RolesClaimsPrincipalFactory doit être ajoutée dans le projet (un Principal est une structure .NET qui porte une identité), avec le contenu suivant :

using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication.Internal;
using System.Security.Claims;
using System.Text.Json;

namespace TestOIDCBlazorWASM.Client
{
    public class RolesClaimsPrincipalFactory : AccountClaimsPrincipalFactory<RemoteUserAccount>
    {
        public RolesClaimsPrincipalFactory(IAccessTokenProviderAccessor accessor) : base(accessor)
        {
        }

        public override async ValueTask<ClaimsPrincipal> CreateUserAsync(RemoteUserAccount account, RemoteAuthenticationUserOptions options)
        {
            var user = await base.CreateUserAsync(account, options);
            if (user?.Identity != null && user.Identity.IsAuthenticated)
            {
                var identity = (ClaimsIdentity)user.Identity;
                var resourceaccess = identity.FindAll("resource_access");
                string Contenu = resourceaccess.First().Value;

                JsonElement elem = JsonDocument.Parse(Contenu).RootElement;
                if (elem.ValueKind == JsonValueKind.Array)
                {
                    foreach (JsonElement e in elem.EnumerateArray())
                        if (e.TryGetProperty("TestOIDCBlazorWASM", out var prop))
                        {
                            foreach (JsonElement role in e.GetProperty("TestOIDCBlazorWASM").GetProperty("roles").EnumerateArray())
                                identity.AddClaim(new Claim(options.RoleClaim, role.GetString() ?? String.Empty));
                            break;
                        }
                }
            }
            return user;
        }
    }
}

A noter que cette classe est une version très simplifiée, sans logs ni gestion d’erreur, et avec toutes les valeurs passées en dur pour faciliter la compréhension. Pour avoir une version un peu plus propre, le lecteur est invité à se rendre sur le dépôt Github https://github.com/jp-gouigoux/TestOIDCBlazorWASM.

Exploitation des rôles côté client

Cette classe se chargeant d’incorporer les rôles dans le Principal à partir des claims, nous pouvons maintenant nous servir des rôles sur le client pour, par exemple, autoriser l’accès à la liste des personnes uniquement si le rôle lecteur est présent. Comme nous avons cassé le fonctionnement de la page Fetch data lorsque nous avons modifié le comportement de l’injection par défaut d’un HttpClient (en ne créant qu’une instance pour les besoins connectés), nous en profiterons pour supprimer ce menu qui ne sert plus. De plus, pour montrer la différence entre la gestion connectée et les rôles, nous masquerons le menu pour la page Counter si la personne n’est pas authentifiée ; seul le menu Home sera accessible en cas d’absence de connexion, ce qui est plus conforme au principe de moindre privilège.

Le code correspondant au menu est le suivant :

<div class="@NavMenuCssClass" @onclick="ToggleNavMenu">
     <nav class="flex-column">
         <div class="nav-item px-3″>
             <NavLink class="nav-link" href="" Match="NavLinkMatch.All">
                 <span class="oi oi-home" aria-hidden="true"></span> Home
             </NavLink>
         </div>
        <AuthorizeView>
             <Authorized>
                 <div class="nav-item px-3″>
                     <NavLink class="nav-link" href="counter">
                         <span class="oi oi-plus" aria-hidden="true"></span> Counter
                     </NavLink>
                 </div>
                 @if (context.User.IsInRole("lecteur"))
                 {
                     <div class="nav-item px-3″>
                         <NavLink class="nav-link" href="personnes">
                             <span class="oi oi-list-rich" aria-hidden="true"></span> Personnes
                         </NavLink>
                     </div>
                 }
             </Authorized>
          </AuthorizeView>
     </nav>
</div>

Logiquement, lors d’un accès non authentifié, le menu est alors réduit à sa plus simple expression :

La connexion avec un compte autorisé permet de voir tous les menus restants :

Et si nous créons dans Keycloak un compte sans aucun rôle, le comportement est le suivant :

Bien sûr, il est également possible de jouer avec les rôles d’un seul utilisateur, en les modifiant dans Keycloak, mais il faudra pour chaque test se déconnecter puis reconnecter :

Toujours sur le client, on peut également modifier l’entête du fichier Personnes.razor pour mettre en œuvre le Role Based Access Control :

@page "/personnes"
@attribute [Authorize(Roles = "lecteur")]

Exploitation des rôles côté serveur

Le même principe s’applique côté serveur, mais afin de montrer la richesse du mécanisme, nous allons cette fois mettre en place des Policies, qui sont des règles d’autorisations. Ce mécanisme, bien qu’utilisé de manière très simple ci-dessous (lien un pour un avec un rôle) permet de créer des politiques de sécurité très complexes si nécessaire.

Pour cela, nous allons modifier le fichier Program.cs du projet Server comme suit :

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(o =>
{
     IConfigurationSection ConfigOIDC = builder.Configuration.GetSection("OIDC");
     o.Authority = ConfigOIDC["Authority"];
     o.Audience = ConfigOIDC["Audience"];
     o.TokenValidationParameters.RoleClaimType = "resource_access.TestOIDCBlazorWASM.roles";
});

builder.Services.AddTransient<IClaimsTransformation, ClaimsTransformer>();

builder.Services.AddAuthorization(o =>
{
     o.AddPolicy("Administration", policy => policy.RequireClaim("user_roles", "administrateur"));
     o.AddPolicy("Lecture", policy => policy.RequireClaim("user_roles", "lecteur"));
});

Ce qui nous intéresse particulièrement est la génération de deux Policies, étant entendu qu’elles reprennent ici simplement les codes des rôles. Encore une fois, il s’agit d’un exemple simpliste pour montrer le principe puisque, si nous avions besoin uniquement des rôles, nous pourrions utiliser la même grammaire que plus haut. Mais on voit bien que, comme la façon d’écrire la Policy est avec une expression lambda, il est possible de combiner toutes les règles imaginables. Les libellés sont toutefois légèrement différents, pour que le lecteur fasse bien la différence par la suite, lorsque les Policies seront utilisées.

Pour que le code compile, il faudra la rajouter la classe suivante, qui se charge de la deuxième fonctionnalité du code présenté, à savoir l’extraction des rôles depuis les claims transmis dans le token :

using Microsoft.AspNetCore.Authentication;
using System.Security.Claims;
using System.Text.Json;

namespace TestOIDCBlazorWASM.Server
{
     public class ClaimsTransformer : IClaimsTransformation
     {
         public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
         {
             ClaimsIdentity claimsIdentity = (ClaimsIdentity)principal.Identity;
             if (claimsIdentity.IsAuthenticated)
             {
                 foreach (var c in claimsIdentity.Clone().FindAll((claim) => claim.Type == "resource_access"))
                 {
                     JsonDocument doc = JsonDocument.Parse(c.Value);
                     foreach (JsonElement elem in doc.RootElement.GetProperty("TestOIDCBlazorWASM").GetProperty("roles").EnumerateArray())
                         claimsIdentity.AddClaim(new Claim("user_roles", elem.GetString() ?? String.Empty));
                 }
             }
             return Task.FromResult(principal);
         }
     }
}

Enfin, l’utilisation des Policies est globalement la même que pour les rôles, seule la grammaire changeant. Dans l’application ci-dessous, le contrôleur est déclaré accessible sur le GET si la politique Lecture est satisfaite, et sur le POST si la politique Administration est satisfaite :

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using TestOIDCBlazorWASM.Shared;

namespace TestOIDCBlazorWASM.Server.Controllers
{
    [Authorize]
    [Route("api/[controller]")]
    [ApiController]
    public class PersonnesController : ControllerBase
    {
        [Authorize(Policy = "Lecture")]
        [HttpGet]
        public IActionResult LecturePersonnes(
            [FromQuery(Name = "$skip")] int skip = 0,
            [FromQuery(Name = "$top")] int top = 10
        )
        {
            return new OkObjectResult(new List<Personne>());
        }

        [Authorize(Policy = "Administration")]
        [HttpPost]
        public IActionResult CreationPersonne([FromBody] Personne personne)
        {
            throw new NotImplementedException("Fonction de création de personne à venir");
        }
    }
}

Lors du lancement, l’erreur suivante apparaît :

Comme nous ne souhaitons pas rentrer dans la complexité d’une exposition HTTPS de Keycloak (pour l’instant, en tout cas), nous suivons le contournement proposé et rajoutons le code suivant dans le code du serveur :

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(o =>
{
    IConfigurationSection ConfigOIDC = builder.Configuration.GetSection("OIDC");
    o.Authority = ConfigOIDC["Authority"];
    o.Audience = ConfigOIDC["Audience"];
    o.TokenValidationParameters.RoleClaimType = "resource_access.TestOIDCBlazorWASM.roles";
    o.RequireHttpsMetadata = false;
});

Le lancement est ensuite conforme à celui attendu :

Conclusion

Au prochain épisode, branchement sur la base de données et ajout d’une page pour créer des personnes, de façon à boucler la partie de base de l’application avec son IAM associée.

Vue la complexité pour écrire le présent article avec Open LiveWriter qui date de plus de 10 ans et le résultat moche pour le code source, je pense qu’il est temps de basculer vers un autre système de bloc, avec peut-être du Markdown pour générer un blog statique… A suivre sur le prochain article de cette série, que j’espère écrire plus vite que celui-ci, longtemps remis à plus tard.

EDIT avril 2023 : c'est désormais chose faite et l'article que vous lisez ainsi que les autres de la série sur l'application TestOIDCBlazorWASM ont été repris sur une plateforme utilisant Pelican en mode Markdown.

links

social