Loupe

Implémenter IdentityServer 4 dans une application ASP.NET Core 2.2

Identity Server est un Framework vous permettant d'utiliser OpenID Connect et Oauth 2 au sein de vos applications ASP.NET Core.

Ce Framework vous permettra de disposer, gratuitement, d'un ensemble de fonctionnalités permettant la gestion d'identité et de contrôle d'accès, à savoir:

  • L'authentification
  • Le SSO
  • Le contrôle d'accès
  • La fédération
  • Etc

Sa mise en place est relativement simple puisque celle-ci repose sur l'utilisation de packages Nuget et l'appel à des méthodes d'extension dans le Startup.cs de votre projet:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddIdentityServer()
            .AddDeveloperSigningCredential();
    }

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

        app.UseIdentityServer();
    }
}

La méthode AddIdentityServer vous permet d'enregistrer les services d'IdentityServer dans le système d'injection de dépendances. UseIdentityServer, de con sôté, vous permet d'indiquer que vous souhaitez utiliser ce service.

Bien que fonctionnel, cet exemple est très simple et, la plupart du temps, vous verrez sur Internet qu'il repose sur l'utilisation des samples pour permettre la création/gestion des comptes. Dans mon cas, je voulais quelque chose de plus évolué, profitant du système mis à disposition par ASP.NET pour gérer les comptes et les identités des utilisateurs.

Pour cela, il convient de commencer en indiquant que l'on souhaite rajouter un élément scaffolé de type "Identity":

Scaffold.PNG

Une fois cette opération terminée, on se retrouve avec l'ensemble des fichiers permettant la gestion / création de compte direcement présent dans son projet Visual Studio:

Scaffold_Results.PNG

Comme on peut le constater, ces fichiers sont situés dans une "Area" donc nous devons indiquer au Framework que notre serveur utilisera des Areas et quelles sont les URLs permettant d'accéder aux pages de Login, Logout, etc.:

services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1)
    .AddRazorPagesOptions(options =>
    {
        options.AllowAreas = true;
        options.Conventions.AuthorizeAreaFolder("Identity", "/Account/Manage");
    });

    services.ConfigureApplicationCookie(options =>
    {
        options.LoginPath = $"/Identity/Account/Login";
        options.LogoutPath = $"/Identity/Account/Logout";
        options.AccessDeniedPath = $"/Identity/Account/AccessDenied";
    });

Pour finir (cette première partie tout du moins), on viens modifier les options d'IdentityServer pour lui donner l'URL de ses pages:

services.AddIdentityServer(options =>
{
    options.UserInteraction.LoginUrl = $"/Identity/Account/Login";
    options.UserInteraction.LogoutUrl = $"/Identity/Account/Logout";
})
.AddDeveloperSigningCredential()            .AddInMemoryIdentityResources(Config.GetIdentityResources())
.AddInMemoryApiResources(Config.GetApiResources())
.AddInMemoryClients(Config.GetClients());

Si vous avez déjà regardé des tutoriels sur IdentityServer, vous avez sans doutes déjà lu des choses à propos des méthodes AddInMemory*. Pour faire simple, Identity Server les ressources que vous voulez exposer / sécuriser ainsi que la liste des clients (Javascript, MVC, etc.) qui auront accès à ces ressources, chaque client pouvnat avoir une méthode d'authentification dédiée. A titre d'information, voici une partie de la classe Config que j'utilise:

public static IEnumerable<IdentityResource> GetIdentityResources()
        {
            return new List<IdentityResource>
            {
                new IdentityResources.OpenId(),
                new IdentityResources.Profile(),
                new IdentityResources.Email(),
            };
        }

        // scopes define the API resources in your system
        public static IEnumerable<ApiResource> GetApiResources()
        {
            return new List<ApiResource>
            {
                new ApiResource("ht-api", "API")
            };
        }

        // clients want to access resources (aka scopes)
        public static IEnumerable<Client> GetClients()
        {
            // client credentials client
            return new List<Client>
            {
                new Client
                {
                    ClientId = "ng",
                    ClientName = "Angular Client",
                    AllowedGrantTypes = GrantTypes.Implicit,
                    AllowAccessTokensViaBrowser = true,
                    RequireConsent = false,
                    PostLogoutRedirectUris = {
                        "http://localhost:4200/"
                    },
                    AllowedCorsOrigins = {
                        "http://localhost:4200"
                    },
                    RedirectUris = {
                        "http://localhost:4200/auth-callback"
                    },
                    AllowedScopes =
                    {
                        IdentityServerConstants.StandardScopes.OpenId,
                        IdentityServerConstants.StandardScopes.Profile,
                        IdentityServerConstants.StandardScopes.Email,
                        "ht-api"
                    }
                },
                new Client
                {
                    ClientId = "mvc",
                    ClientName = "MVC Client",
                    AllowedGrantTypes = GrantTypes.HybridAndClientCredentials,
                    RequireConsent = false,
                    ClientSecrets =
                    {
                        new Secret("secret".Sha256())
                    },
                    RedirectUris           = { "https://localhost:5001/signin-oidc" },
                    PostLogoutRedirectUris = { "https://localhost:5001/signout-callback-oidc" },
                    AllowedScopes =
                    {
                        IdentityServerConstants.StandardScopes.OpenId,
                        IdentityServerConstants.StandardScopes.Profile,
                        IdentityServerConstants.StandardScopes.Email,
                        "api1"
                    },
                    AllowOfflineAccess = true
                }
            };
        }

Dans un scénario ideal, il ne faut pas utiliser des ressources en mémoire mais plutôt utiliser les Stores permettant la persistence des données au sein d'une base de données, vie Entity Framework Core par exemple (mais nous aurons le temps de voir ce point dans un projet article).

Etant donné que l'on souhaite utiliser le système d'identité d'ASP.NET Core pour gérer la liste de nos utilisateurs, nous devons configurer le Framework en ce sens. Pour cela, on commence par rajouter les services nécessaires:

services.AddDbContext<ApplicationDbContext>(options =>      options.UseSqlServer(Configuration.GetConnectionString("ApplicationDbContextConnection")));

services.AddIdentity<ApplicationUser, ApplicationRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();

(Dans le cas présent, je passe par une classe spécifique pour la gestion des utilisateurs / rôles, n'étant pas fan d'avoir des identifiants uniques sous forme de string):

public class ApplicationUser : IdentityUser<Guid>
{
}

public class ApplicationRole : IdentityRole<Guid>
{
}

Pour finir, il nous faut indiquer à Identity Server que nous souhaitons utiliser le système d'identité d'ASP.NET Core pour gérer nos utilisateurs. Et cela se fait tout simplement via la méthode suivante:

.AddAspNetIdentity<ApplicationUser>()

A partir de là, nous pouvons maintenant nous connecter à notre serveur d'authentification, par exemple depuis une application Angular qui utilise un client OIDC. Techniquement, étant donné que nous sommes dans une application Angular, nous ne voulons pas que l'utilisateur saisisse son login et son mot de passe directement dans l'application pour que ceux-ci soient transmis en clair sur le réseau. Et c'est là l'une des forces d'Identity Server. Ayant configuré notre client Angular avec une authentification de type "Implicit", le client Angular va être redirigé vers la page de login du serveur d'authentification pour celui-ci renverra l'utilisateur vers l'application Angular, au moyen de la "redirect_uri":

Auth1.PNG      Auth2.PNG Auth3.PNG

Et voilà, à partir de là, votre utilisateur est authentifié et le Bearer qu'il a obtenu de la part de votre serveur peut être utilisé pour appeler une de vo APIS, celle-ci étant elle-même sécurisée (et dont la validation de token s'effectue aurpès de votre serveur Identity Server)!

Last, but not least: si vous souhaitez modifier le processus d'authentification pour rajouter des Claims à vos utilisateurs, vous devez créer une classe qui implémente l'interface IProfileService d'Identity Server. A partir de cette classe, vous serez en mesure d'accéder au UserManager d'ASP.NET (et donc de récupérer l'utilisateur qui fait la requête) pour valider ses droits, etc.:

public class MyProfileService : IProfileService
    {
        private readonly IUserClaimsPrincipalFactory<ApplicationUser> _claimsFactory;
        private readonly UserManager<ApplicationUser> _userManager;

        public MyProfileService(UserManager<ApplicationUser> userManager, IUserClaimsPrincipalFactory<ApplicationUser> claimsFactory)
        {
            _userManager = userManager;
            _claimsFactory = claimsFactory;
        }

        public async Task GetProfileDataAsync(ProfileDataRequestContext context)
        {
            var sub = context.Subject.GetSubjectId();
            var user = await _userManager.FindByIdAsync(sub);
            var principal = await _claimsFactory.CreateAsync(user);

            var claims = principal.Claims.ToList();

            claims = claims.Where(claim => context.RequestedClaimTypes.Contains(claim.Type)).ToList();

            claims.Add(new Claim(JwtClaimTypes.GivenName, user.UserName));

            claims.Add(new Claim("MonSuperClaims", "Hello World!"));
            claims.Add(new Claim(IdentityServerConstants.StandardScopes.Email, user.Email));

            context.IssuedClaims = claims;
        }

        public async Task IsActiveAsync(IsActiveContext context)
        {
            var sub = context.Subject.GetSubjectId();
            var user = await _userManager.FindByIdAsync(sub);

            context.IsActive = user != null;
        }
    }

Penser, bien sûr, à l'ajouter à la déclaration d'Identity Server:

.AddAspNetIdentity<ApplicationUser>()
.Services.AddTransient<IProfileService, MyProfileService>();

Et le tour est joué !

Auth4.PNG

Et voilà! En quelques lignes de code, vous êtes en mesure de disposer, simplement et rapidement, d'un serveur OpenID Connect / OAuth 2 qui est à la fois puissant et très extensible (nous y reviendrons d'ailleurs prochainement).

 

Happy coding!

 

Ces billets pourraient aussi vous intéresser

Vous nous direz ?!

Commentaires

comments powered by Disqus