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:
:fleche: Mise en place d’un STS avec IdentityServer4 pour sécuriser ses applications .NET
:fleche: Sécurisation d’une Web API ASP.NET Core avec le STS IdentityServer4
:fleche: IdentityServer4: création et configuration du Client pour accéder à une Web API ASP.NET Core sécurisée
:fleche: IdentityServer4 : Authentification d’un utilisateur avec OpenID Connect
:fleche: IdentityServer4 : création et configuration d’un client utilisant OpenID Connect
:fleche: IdentityServer4 : Autoriser l’application MVC à accéder à l’API, via le jeton obtenu du STS
:fleche: IdentityServer4 : prise en charge du provider tiers Microsoft pour l'authentification
:fleche: IdentityServer4 : prise en charge du provider tiers Github pour l'authentification
:fleche: IdentityServer4 : persistance des données de configuration avec EntityFramework Core
:fleche: 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/identityserver/tree/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 :
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 :
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 :
public class UserRepository : IUserRepository
{
private List
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 :
services.AddTransient
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/blogs/137868-hinault-romaric/b3552/injection-dependances-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.
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;
}
///
/// Entry point into the login workflow
///
public async Task
{
// 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);
}
///
/// Handle postback from username/password login
///
public async Task
{
// 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 :
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
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
C’est tout. Vous pouvez tester votre application et vous authentifier en utilisant l’application MvcAppClient :
575630
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 :
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 :
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 :
services.AddIdentityServer()
.AddDeveloperSigningCredential()
.AddInMemoryIdentityResources(Config.GetIdentityResources())
.AddInMemoryApiResources(Config.GetApiResources())
.AddResourceOwnerValidator
.AddProfileService
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 :
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 :
575631
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 :
- http://docs.identityserver.io/en/release/index.html
- https://github.com/IdentityServer/IdentityServer4.Quickstart.UI
- https://github.com/IdentityServer/IdentityServer4
Vous avez lu gratuitement 423 articles depuis plus d'un an.
Soutenez le club developpez.com en souscrivant un abonnement pour que nous puissions continuer à vous proposer des publications.
Soutenez le club developpez.com en souscrivant un abonnement pour que nous puissions continuer à vous proposer des publications.