Voir le flux RSS

Blog de Hinault Romaric (.NET Core, ASP.NET Core, Azure, DevOps)

[Actualité] IdentityServer4 : IResourceOwnerPasswordValidator, IProfileService et service custom d'accès aux données

Noter ce billet
par , 28/07/2020 à 19h34 (639 Affichages)
IdentityServer est une solution open source .NET de gestion d’identité et de contrôle d’accès. Il repose sur les protocoles OpenID Connect et OAuth 2.0.

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 - Visualiser dans une fenêtre à part
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 - Visualiser dans une fenêtre à part
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 - Visualiser dans une fenêtre à part
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 - Visualiser dans une fenêtre à part
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 - Visualiser dans une fenêtre à part
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 - Visualiser dans une fenêtre à part
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 :

Nom : img10.png
Affichages : 703
Taille : 16,0 Ko

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 - Visualiser dans une fenêtre à part
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 - Visualiser dans une fenêtre à part
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 - Visualiser dans une fenêtre à part
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 - Visualiser dans une fenêtre à part
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 :

Nom : img11.png
Affichages : 651
Taille : 31,5 Ko

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 :


Envoyer le billet « IdentityServer4 : IResourceOwnerPasswordValidator, IProfileService et service custom d'accès aux données » dans le blog Viadeo Envoyer le billet « IdentityServer4 : IResourceOwnerPasswordValidator, IProfileService et service custom d'accès aux données » dans le blog Twitter Envoyer le billet « IdentityServer4 : IResourceOwnerPasswordValidator, IProfileService et service custom d'accès aux données » dans le blog Google Envoyer le billet « IdentityServer4 : IResourceOwnerPasswordValidator, IProfileService et service custom d'accès aux données » dans le blog Facebook Envoyer le billet « IdentityServer4 : IResourceOwnerPasswordValidator, IProfileService et service custom d'accès aux données » dans le blog Digg Envoyer le billet « IdentityServer4 : IResourceOwnerPasswordValidator, IProfileService et service custom d'accès aux données » dans le blog Delicious Envoyer le billet « IdentityServer4 : IResourceOwnerPasswordValidator, IProfileService et service custom d'accès aux données » dans le blog MySpace Envoyer le billet « IdentityServer4 : IResourceOwnerPasswordValidator, IProfileService et service custom d'accès aux données » dans le blog Yahoo

Mis à jour 30/07/2020 à 19h42 par Malick

Catégories
DotNET , ASP.NET , .NET Core , ASP.NET Core

Commentaires