IdentityServer peut être utilisé par les entreprises pour mettre en place une solution pour :
- la protection de leurs ressources ;
- l’authentification des utilisateurs via une base de données ou des fournisseurs externes d’identité (Microsoft, Google, Facebook, etc.) ;
- la gestion des sessions et la fédération (single sign-on) ;
- la génération des jetons pour les clients ;
- la validation des jetons et bien plus.
Ce billet est le onzième que j’écris sur le sujet. Les billets précédents ont porté sur les points suivants :
Mise en place d’un STS avec IdentityServer4 pour sécuriser ses applications .NET
Sécurisation d’une Web API ASP.NET Core avec le STS IdentityServer4
IdentityServer4 : création et configuration du Client pour accéder à une Web API ASP.NET Core sécurisée
IdentityServer4 : Authentification d’un utilisateur avec OpenID Connect
IdentityServer4 : création et configuration d’un client utilisant OpenID Connect
IdentityServer4 : Autoriser l’application MVC à accéder à l’API, via le jeton obtenu du STS
IdentityServer4 : prise en charge du provider tiers Microsoft pour l'authentification
IdentityServer4 : prise en charge du provider tiers Github pour l'authentification
IdentityServer4 : persistance des données de configuration avec EntityFramework Core
IdentityServer4 : utiliser ASP.NET Core Identity pour l’authentification
Dans l'un des billets précédents, nous avons vu comment utiliser OpenID et permettre à l’utilisateur de s’authentifier via un formulaire. Pour la mise en place de la fenêtre de connexion, de déconnexion, etc., nous avons utilisé un Quickstart offert par IdentityServer. Ce modèle repose sur TestUserStore, qui nous permet de définir et charger nos utilisateurs depuis un fichier inclus dans le projet.
Le TestUserStore est offert à des fins de tests pour permettre aux développeurs de démarrer facilement avec la prise en main de l’outil. Dans un projet concret d’entreprise, vous aurez votre propre base de données utilisateurs et utiliserez ce dernier pour l’authentification.
Dans ce billet, nous verrons comment authentifier l’utilisateur en utilisant notre propre service d’accès aux données et comment définir les revendications de l’utilisateur.
Nous utiliserons comme projet de base la solution suivante qui est disponible sur mon GitHub : https://github.com/hinault/identitys...ee/aspnetcore2.
Création du service
La première chose à faire sera de définir notre classe entité Utilisateur. Pour cela, nous allons créer dans le dossier Model la classe CustomUser suivante :
Code c# : | Sélectionner tout |
1 2 3 4 5 6 7 8 | public class CustomUser { public string SubjectId { get; set; } public string UserName { get; set; } public string Password { get; set; } public string FirstName { get; set; } public string Email { get; set; } } |
La deuxième étape sera la création du UserRepository. Nous allons tout d’abord créer l’interface IUserRepository dans le dossier Repository du projet :
Code c# : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 | public interface IUserRepository { CustomUser FindByUserName(string userName); CustomUser FindBySubjectId(string subjectId); bool ValidateCredentials(string userName, string password); } |
Ensuite, nous allons ajouter l’implémentation de cette interface :
Code c# : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | public class UserRepository : IUserRepository { private List<CustomUser> _customUsers = new List<CustomUser> { new CustomUser { SubjectId = "1111", UserName = "alice", FirstName = "Alice Smith", Email = "AliceSmith@email.com", Password = "alice" }, new CustomUser { SubjectId = "2222", UserName = "bob", FirstName = "Bob Smith", Email = "BobSmith@email.com", Password = "bob" } }; public CustomUser FindBySubjectId(string subjectId) { return _customUsers.Find(x => x.SubjectId.Equals(subjectId)); } public CustomUser FindByUserName(string userName) { return _customUsers.Find(x=>x.UserName.Equals(userName)); } public bool ValidateCredentials(string userName, string password) { var customUser = FindByUserName(userName); return customUser != null && customUser.Password.Equals(password); } } |
Afin que l’initialisation de ce service puisse se faire correctement, nous devons enregistrer celui-ci dans notre conteneur d’IoC ASP.NET Core. Vous devez éditer la méthode ConfigureServices du fichier Startup.cs et ajouter la ligne de code suivante :
Code c# : | Sélectionner tout |
services.AddTransient<IUserRepository, UserRepository>();
Pour en savoir plus sur l’injection de dépendances avec ASP.NET Core, veuillez consulter mon billet de blog suivant : https://www.developpez.net/forums/bl...-asp-net-core/
Le contrôleur AccourntController doit être modifié pour utiliser le UserRepository et sa méthode ValidateCredentials() pour valider l’identité de l’utilisateur.
Code c# : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 | private readonly IUserRepository _userRepository; public AccountController( IIdentityServerInteractionService interaction, IClientStore clientStore, IAuthenticationSchemeProvider schemeProvider, IEventService events, IUserRepository userRepository) { _interaction = interaction; _clientStore = clientStore; _schemeProvider = schemeProvider; _events = events; _userRepository = userRepository; } /// <summary> /// Entry point into the login workflow /// </summary> [HttpGet] public async Task<IActionResult> Login(string returnUrl) { // build a model so we know what to show on the login page var vm = await BuildLoginViewModelAsync(returnUrl); if (vm.IsExternalLoginOnly) { // we only have one option for logging in and it's an external provider return RedirectToAction("Challenge", "External", new { provider = vm.ExternalLoginScheme, returnUrl }); } return View(vm); } /// <summary> /// Handle postback from username/password login /// </summary> [HttpPost] [ValidateAntiForgeryToken] public async Task<IActionResult> Login(LoginInputModel model, string button) { // check if we are in the context of an authorization request var context = await _interaction.GetAuthorizationContextAsync(model.ReturnUrl); // the user clicked the "cancel" button if (button != "login") { if (context != null) { // if the user cancels, send a result back into IdentityServer as if they // denied the consent (even if this client does not require consent). // this will send back an access denied OIDC error response to the client. await _interaction.GrantConsentAsync(context, ConsentResponse.Denied); // we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null if (await _clientStore.IsPkceClientAsync(context.ClientId)) { // if the client is PKCE then we assume it's native, so this change in how to // return the response is for better UX for the end user. return View("Redirect", new RedirectViewModel { RedirectUrl = model.ReturnUrl }); } return Redirect(model.ReturnUrl); } else { // since we don't have a valid context, then we just go back to the home page return Redirect("~/"); } } if (ModelState.IsValid) { // validate username/password against in-memory store if (_userRepository.ValidateCredentials(model.Username, model.Password)) { var user = _userRepository.FindByUserName(model.Username); await _events.RaiseAsync(new UserLoginSuccessEvent(user.UserName, user.SubjectId, user.UserName)); // only set explicit expiration here if user chooses "remember me". // otherwise we rely upon expiration configured in cookie middleware. AuthenticationProperties props = null; if (AccountOptions.AllowRememberLogin && model.RememberLogin) { props = new AuthenticationProperties { IsPersistent = true, ExpiresUtc = DateTimeOffset.UtcNow.Add(AccountOptions.RememberMeLoginDuration) }; }; // issue authentication cookie with subject ID and username await HttpContext.SignInAsync(user.SubjectId, user.UserName, props); if (context != null) { if (await _clientStore.IsPkceClientAsync(context.ClientId)) { // if the client is PKCE then we assume it's native, so this change in how to // return the response is for better UX for the end user. return View("Redirect", new RedirectViewModel { RedirectUrl = model.ReturnUrl }); } // we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null return Redirect(model.ReturnUrl); } // request for a local page if (Url.IsLocalUrl(model.ReturnUrl)) { return Redirect(model.ReturnUrl); } else if (string.IsNullOrEmpty(model.ReturnUrl)) { return Redirect("~/"); } else { // user might have clicked on a malicious link - should be logged throw new Exception("invalid return URL"); } } await _events.RaiseAsync(new UserLoginFailureEvent(model.Username, "invalid credentials")); ModelState.AddModelError("", AccountOptions.InvalidCredentialsErrorMessage); } |
Implémentation de l’interface IProfileService
Nous voulons que certaines informations de l’utilisateur (Email, nom, etc.) soient partagées avec les applications qui viennent s’authentifier via IdentityServer. Ces informations doivent être incluses dans les Claims (Revendications). Pour mettre cela en place, nous devons fournir notre propre implémentation de l’interface IProfileService :
Code c# : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | public class ProfileService : IProfileService { private IUserRepository _userRepository { get; set; } public ProfileService(IUserRepository userRepository) { _userRepository = userRepository; } public Task GetProfileDataAsync(ProfileDataRequestContext context) { var custormUser = _userRepository.FindBySubjectId(context.Subject.FindFirst(x => x.Type == "sub").Value); if (custormUser != null) { context.IssuedClaims = GetClaims(custormUser); } return Task.FromResult(0); } public Task IsActiveAsync(IsActiveContext context) { return Task.FromResult(0); } private List<Claim> GetClaims(CustomUser customUser) => new List<Claim> { new Claim(JwtClaimTypes.Name, customUser.UserName), new Claim(JwtClaimTypes.FamilyName, customUser.FirstName), new Claim(JwtClaimTypes.Email, customUser.Email) }; } |
Modification de la configuration d’IdentityServer
Nous devons maintenant modifier la configuration d’IdentityServer pour enregistrer notre implémentation de IProfileService et supprimer l’enregistrement du TestUserStore :
services.AddIdentityServer()
.AddDeveloperSigningCredential()
.AddInMemoryIdentityResources(Config.GetIdentityResources())
.AddInMemoryApiResources(Config.GetApiResources())
.AddProfileService<ProfileService>();
C’est tout. Vous pouvez tester votre application et vous authentifier en utilisant l’application MvcAppClient :
Implémentation de l’interface IResourceOwnerPasswordValidator
Su vous souhaitez que d’autres clients puissent obtenir des jetons d’authentification en fournissant directement leur nom d’utilisateur et leur mot de passe au token endpoint, vous devez fournir votre propre implémentation de l’interface IResourceOwnerPasswordValidator.
Supposons que nous voulons que l’application ConsoleAppClient utilise ce mode. Nous allons dans un premier temps changer son GrantTypes dans le Config.cs pour utiliser ResourceOwnerPassword :
Code c# : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 | new Client { ClientId = "consoleappclient", AllowedGrantTypes = GrantTypes.ResourceOwnerPassword, ClientSecrets = { new Secret("secret".Sha256()) }, AllowedScopes = { "testapi" } }, |
Ensuite implémenter l’interface IResourceOwnerPasswordValidator pour valider l’identité de l’utilisateur en utilisant notre UserRepository :
Code c# : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | public class ResourceOwnerPasswordValidator : IResourceOwnerPasswordValidator { private IUserRepository _userRepository { get; set; } public ResourceOwnerPasswordValidator(IUserRepository userRepository) { _userRepository = userRepository; } public Task ValidateAsync(ResourceOwnerPasswordValidationContext context) { var customUser = _userRepository.FindByUserName(context.UserName); if (customUser != null && customUser.Password.Equals(context.Password)) { context.Result = new GrantValidationResult( subject: customUser.SubjectId, authenticationMethod: OidcConstants.AuthenticationMethods.Password); } else { context.Result = new GrantValidationResult( TokenRequestErrors.InvalidGrant, "invalid credential"); } return Task.FromResult(0); } } |
Modifier la configuration de IdentityServer pour utiliser notre implémentation de cette interface :
Code c# : | Sélectionner tout |
1 2 3 4 5 6 | services.AddIdentityServer() .AddDeveloperSigningCredential() .AddInMemoryIdentityResources(Config.GetIdentityResources()) .AddInMemoryApiResources(Config.GetApiResources()) .AddResourceOwnerValidator<ResourceOwnerPasswordValidator>() .AddProfileService<ProfileService>(); |
Maintenant, il ne nous reste plus qu’à mettre à jour le client pour passer ses informations d’identification lors de l’appel d’une ressource sécurisée :
Code c# : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | static async Task CallWebApiR() { // discover endpoints from metadata var disco = await DiscoveryClient.GetAsync("https://localhost:5001"); if (disco.IsError) { Console.WriteLine(disco.Error); return; } // request token var tokenClient = new TokenClient(disco.TokenEndpoint, "consoleappclient", "secret"); var tokenResponse = await tokenClient.RequestResourceOwnerPasswordAsync("alice", "alice", "testapi"); if (tokenResponse.IsError) { Console.WriteLine(tokenResponse.Error); return; } Console.WriteLine(tokenResponse.Json); // call api var client = new HttpClient(); client.SetBearerToken(tokenResponse.AccessToken); var response = await client.GetAsync("https://localhost:5003/api/secure"); if (!response.IsSuccessStatusCode) { Console.WriteLine(response.StatusCode); } else { var content = await response.Content.ReadAsStringAsync(); Console.WriteLine(JArray.Parse(content)); } } |
À l’exécution, on obtient ce qui suit :
Maintenant, vous êtes capables d'intégrer vos services d'accès aux données d'authentification avec IdentityServer, fournir votre propre implémentation de certaines interfaces pour partager des informations dans les revendications et fournir votre propre mécanisme de validation de mot de passe.
Références :