Identity Server
Présentation
IdentityServer est une librairie open source, développée par la société Duende, qui apporte le support du protocole OAuth 2.0 et sa surcouche OpenID Connect. Cette librairie vous permettra d’interagir avec les applications et les API qui auraient besoin d’obtenir ou de vérifier un token d’authentification, pas forcément un JWT d’ailleurs, en respectant les standards en vigueur.
IdentityServer est une librairie qui a vu le jour en 2009 sous le nom de StarterSTS. À l’époque, pas question de OAuth 2.0, le protocole est apparu en 2012, mais de WS-Federation, un ancien protocole qui n’est plus très utilisé de nos jours.
Puis sont apparus IdentityServer 1 et 2 qui ont apporté le support d’autres protocoles, dont OAuth 2.0, sortis depuis.
2014 a vu arriver IdentityServer 3 qui apportait le support d’OpenID Connect.
En 2016, c’est la version 4 qui a vu le jour. Avec elle est arrivé le support de .Net Core, mais également l’abandon des autres anciens protocoles pour ne conserver qu’OAuth2 et OpenID Connect qui étaient devenus les standards. La version 4 est la version qui a démocratisé IdentityServer et l’a vraiment rendu populaire, obtenant même un soutien de Microsoft qui en a fait la librairie standard pour l’authentification des projets Blazor. Le développement...
Installation
IdentityServer est distribué sous la forme d’un ensemble de Packages NuGet :
Pour la persistance des données, les interfaces fournies fonctionnent avec EntityFramework Core. Si vous souhaitez utiliser un autre ORM, il faudra réimplémenter vous-même les mécaniques de sauvegarde dans votre base de données.
Le package principal est Duende.IdentityServer, c’est celui-ci qu’il faudra installer en premier :
dotnet add package Duende.IdentityServer
Comme nous utilisons ASP.Net Identity pour la gestion des utilisateurs, nous allons également ajouter le package Duende.IdentityServer.AspNetIdentity.
dotnet add package Duende.IdentityServer.AspNetIdentity
Et comme nos données sont persistées au travers d’EntityFramework Core, nous allons ajouter le package nécessaire à la liaison entre les stores IdentityServer et EntityFramework Core :
dotnet add package Duende.IdentityServer.EntityFramework
Pour consommer IdentityServer, il faudra ajouter les services nécessaires dans l’injection de dépendances :
builder.Services.AddIdentityServer();
La méthode AddIdentityServer() retourne un IIdentityServerBuilder et peut être configurée de deux manières : en y injectant une instance de IConfiguration contenant les bonnes propriétés, ou en réglant directement les options à disposition en paramètre de la méthode. Ces options étant extrêmement nombreuses et pointues, nous allons nous contenter des options de base pour le moment. Celles-ci font tout à fait l’affaire dans la majorité des cas.
Les données d’IdentityServer, que ce soit pour la configuration ou le fonctionnement, doivent être persistées dans des magasins. IdentityServer fournit les magasins et des implémentations de ceux-ci pour la persistance avec EntityFramework Core :
.AddConfigurationStore(o =>
o.ConfigureDbContext = ctx =>
ctx.UseSqlServer(connectionString,
...
Les protocoles OAuth2 et Open ID Connect (OIDC)
Ces deux protocoles ont des fonctions bien distinctes. OAuth est un protocole d’autorisation alors qu’OIDC est un protocole d’authentification. Ils n’ont donc pas la même vocation et ne véhiculent pas les mêmes informations. OIDC étant construit comme une surcouche d’OAuth, les deux se mêlent sans que l’on perçoive la différence lors de l’utilisation d’OIDC. La distinction entre les deux peut se visualiser grâce aux tokens émis :
-
Access token : autorisation donc OAuth.
-
ID token : identification donc OIDC.
Pour le reste, les deux protocoles reposent sur les mêmes couches fonctionnelles, les mêmes protocoles d’échange, etc.
Les flux d’authentification
1. Authorization Code
Le flux d’authentification Authorization code est le plus répandu pour l’authentification d’un utilisateur humain. Il est également assez complexe avec de nombreux allers-retours entre le client et le serveur.
1. Le client appelle le endpoint Authorize pour recevoir l’autorisation de demander un token.
2. Si l’utilisateur n’est pas déjà authentifié, le SSO redirige celui-ci vers la page de connexion pour qu’il saisisse ses identifiants.
3. Les identifiants de l’utilisateur sont soumis au SSO qui va procéder à l’authentification.
4. Si l’authentification est valide, le SSO retourne un code à durée de vie limitée (5 minutes par défaut).
5. L’utilisateur demande le token en fournissant le code qui lui a été transmis.
6. L’utilisateur reçoit son token, le flux est terminé.
2. Proof Key for Code Exchange (PKCE)
La PKCE est une extension du flux Authorization code visant à sécuriser celui-ci pour le protéger de diverses attaques. Les différences se font aux étapes 1 et 5 du flux. À l’étape 1, lors de la demande d’autorisation, on insère dans la requête la valeur hashée en SHA256 d’une valeur arbitraire que l’on conservera secrète. Un double GUID, par exemple. Un GUID unique ne suffit pas, la longueur de la valeur devant faire au minimum 43 caractères par défaut. Le SSO conservera cette valeur hashée pour...
Les stores
Les stores d’Identity Server sont des magasins de données qui contiennent les informations nécessaires au bon fonctionnement du SSO. Ces stores sont sauvegardés en bases de données grâce à l’interfaçage avec EntityFramework Core évoqué en début de chapitre.
1. Resource store
Ce magasin stocke les ressources qui seront consommées par les clients : ApiResource, IdentityResource, ApiScope, et fournit les méthodes pour y accéder. Ce magasin est persisté en base de données dans les tables :
-
ApiResources
-
ApiResourceScopes
-
ApiResourceSecrets
-
ApiScopes
-
ApiScopeClaims
-
ApiScopeProperties
-
IdentityResources
-
IdentityResourceProperties
-
IdentityResourceClaims
2. Client store
Ce magasin stocke les applications clientes qui sont autorisées à consommer le SSO. Ce magasin est persisté en base de données dans les tables :
-
Clients
-
ClientClaims
-
ClientCorsOrigins
-
ClientGrantTypes
-
ClientIdPRestrictions
-
ClientPostLogoutRedirectUris
-
ClientProperties
-
ClientRedirectUris
-
ClientScopes
-
ClientSecrets
3. Identity providers store
Ce magasin contient les fournisseurs d’authentification externes, il est persisté dans la table IdentityProviders.
4. Persisted grant store
Ce magasin contient les informations d’authentification qui doivent être persistées : Authorization code, Refresh token, Access token de type reference...
Les ressources
Les ressources représentent des données ou des API auxquelles le porteur (Bearer) du token peut accéder. Pour accéder à une ou plusieurs ressources, le demandeur va demander un scope. Ce scope représente soit une IdentityResource s’il s’agit de données concernant l’utilisateur, soit un ApiScope ou une ApiResource si la ressource concerne une API.
1. IdentityResource
Cette ressource représente une information sur l’identité de l’utilisateur. Le protocole OIDC prévoit plusieurs scopes standards qui permettent d’accéder aux données de l’utilisateur. Ces scopes sont matérialisés sous forme d’IdentityResource dans IdentityServer.
Chaque IdentityResource permet l’accès à un ou plusieurs claims correspondant à une donnée utilisateur.
-
openid : sub
-
profile : name, family_name, given_name, middle_name, nickname, preferred_username, profile, picture, website, gender, birthdate, zoneinfo, locale, updated_at
-
email : email, email_verified
-
phone : phone_number, phone_number_verified
-
offline_access : celui-ci est un peu particulier car il autorise l’émission du refresh token.
Ceci étant présenté, regardons la classe IdentityResource :
public class IdentityResource : Resource
{
public IdentityResource()
{
}
public IdentityResource(string name, IEnumerable<string>
userClaims): this(name, name, userClaims)
{
}
public IdentityResource(string name, string displayName,
IEnumerable<string> userClaims)
{
if (name.IsMissing()) throw new ArgumentNullException(nameof(name));
if (userClaims.IsNullOrEmpty()) throw new ArgumentException("Must
provide at least one claim type", nameof(userClaims));
Name = name;
DisplayName = displayName; ...
Les clients
Un client est une application qui désire consommer l’authentification fournie par le SSO. Cette application peut être une application web classique, une application single page, une application desktop, mobile, un service autonome, etc.
Une application cliente est définie par la classe Client :
public class Client
{
public bool Enabled { get; set; } = true;
public string ClientId { get; set; } = default!;
public string ProtocolType { get; set; } =
IdentityServerConstants.ProtocolTypes.OpenIdConnect;
public ICollection<Secret> ClientSecrets { get; set; } =
new HashSet<Secret>();
public bool RequireClientSecret { get; set; } = true;
public string? ClientName { get; set; }
public string? Description { get; set; }
public string? ClientUri { get; set; }
public string? LogoUri { get; set; }
public bool RequireConsent { get; set; } = false;
public bool AllowRememberConsent { get; set; } = true;
public ICollection<string> AllowedGrantTypes= new HashSet<string>();
public bool RequirePkce { get; set; } = true;
public bool AllowPlainTextPkce { get; set; } = false;
public bool RequireRequestObject { get; set; } = false;
public bool AllowAccessTokensViaBrowser { get; set; } = false;
public ICollection<string> RedirectUris { get; set; } =
new HashSet<string>();
public ICollection<string> PostLogoutRedirectUris { get; set; } =
new HashSet<string>();
public string? FrontChannelLogoutUri { get; set; }
public bool FrontChannelLogoutSessionRequired { get; set; } = true;
public string? BackChannelLogoutUri { get; set; }
...
IdentityServer et EntityFramework Core
Comme nous l’avons vu lors de la configuration, IdentityServer propose un interfaçage avec EntityFramework Core pour la persistance des données en base.
Cette persistance se fait au travers de deux DbContext : l’un pour les configurations, le ConfigurationDbContext, et l’autre pour les tokens et les codes qui doivent être sauvegardés, le PersistedGrantDbContext.
Le PersistedGrantDbContext est géré en totalité par IdentityServer et, sauf exception, vous n’aurez pas à regarder ce qui s’y passe.
Le ConfigurationDbContext nous intéresse beaucoup plus car il contient les données de configuration de notre SSO. Toutefois, les classes que nous venons d’étudier ne peuvent pas directement être persistées en base de données. Il faut les mapper sur des entités qui correspondent aux tables de la base de données.
Pour cela, pour chaque classe de modèle de donnée : IdentityResource, ApiResource et ApiScope, il existe une méthode d’extension .ToEntity() qui se charge de faire le mapping pour vous afin que vous vous puissiez enregistrer vos informations en base de données.
Les tokens
Les jetons, ou tokens, sont la base des systèmes OAuth et OpenId Connect. Comme nous l’avons abordé lors de la création de notre propre JWT, il s’agit d’une chaîne de caractère encodée en base 64 dont l’authenticité est assurée par un hash de signature. Toutefois, contrairement à notre implémentation maison, OAuth propose plusieurs types de tokens et ils ont des rôles bien particuliers que nous allons aborder ici.
1. JSON Web Token
Les JSON Web Token ou JWT sont des tokens au porteur (Bearer) qui permettent de justifier de droits d’accès auprès d’une application. Ce token est un objet JSON qui contient une liste de propriétés, normées pour certaines, libres pour d’autres.
Ce token a un très gros avantage : le token contenant déjà toutes les informations utiles, il n’est pas nécessaire de solliciter votre SSO pour obtenir des informations complémentaires. De plus, le token contient les identifiants des clés nécessaires à sa validation. Ainsi, l’application qui reçoit le token peut valider elle-même que celui-ci est authentique sans solliciter votre système. Ce token étant au porteur, le SSO n’en conserve pas de trace, il est donc irrévocable.
C’est un point extrêmement important à conserver à l’esprit. Un token JWT une fois émis est valide jusqu’à son expiration. S’il vous est dérobé, vous êtes à la merci du pirate jusqu’à sa date d’expiration. Dès lors, vous comprendrez qu’il est extrêmement déconseillé de produire des tokens avec des durées de vie longues. Un access token a une durée de vie par défaut d’une heure. Imaginez les dégâts...
Endpoints
Les endpoints sont les points de terminaison d’une API Web. Autrement dit, ce sont les points d’accès à une API Web, soit les routes que les utilisateurs vont appeler pour consommer votre API Web.
1. Discovery Endpoint
En début de chapitre, nous avons rapidement abordé le Discovery endpoint. Le Discovery retourne un flux JSON qui contient différentes sections que nous allons détailler :
"issuer": https://localhost:7191
Issuer est l’émetteur du token. Par défaut, c’est l’URL courante de votre SSO mais vous pouvez surcharger ce paramètre dans la configuration d’IdentityServer.
"jwks_uri":
https://localhost:7191/.well-known/openid-configuration/jwks
URL à laquelle on pourra obtenir les clés de sécurité pour authentifier les tokens comme nous l’avons fait avec notre implémentation maison.
"authorization_endpoint":
https://localhost:7191/connect/authorize
URL à laquelle vous vous adresserez pour savoir si vous êtes autorisés à demander un token ou non (si vous êtes authentifié ou non). Dans le cas contraire, Identity Server vous redirigera directement vers la page de connexion.
"token_endpoint": https://localhost:7191/connect/token
URL qui délivre les tokens, une fois authentifié.
"userinfo_endpoint": https://localhost:7191/connect/userinfo
URL qui fournit les informations de l’utilisateur courant. Ces informations peuvent également être transmises dans l’ID token si le système est configuré pour cela.
"end_session_endpoint": https://localhost:7191/connect/endsession
URL appelée pour la déconnexion.
"check_session_iframe":
https://localhost:7191/connect/checksession
URL de vérification de l’état de session.
"revocation_endpoint": https://localhost:7191/connect/revocation
URL de révocation des tokens. Permet de supprimer les refresh tokens et les access token de type reference uniquement, pas les JWT.
"introspection_endpoint":
https://localhost:7191/connect/introspect
URL permettant de valider un token dans le cas d’un access token de type reference ou dans le cas où le client ne serait pas en mesure de valider un JWT par ses propres moyens.
"device_authorization_endpoint":...
Configuration d’IdentityServer
Nous avons vu les différentes parties qui composent IdentityServer. Tous ces éléments fonctionnent ensemble selon des règles préétablies que vous pouvez modifier grâce aux propriétés de configuration d’IdentityServer.
Lorsque vous ajoutez IdentityServer à votre conteneur de services via la commande :
builder.Services.AddIdentityServer(o =>
{
......
});
Vous avez à disposition des dizaines de propriétés configurables. Les valeurs présentées sont les valeurs par défaut des propriétés :
o.AccessTokenJwtType = "at+jwt";
Le type d’accès token retourné.
o.Authentication.CheckSessionCookieDomain = null;
Domaine utilisé pour le cookie de session IdentityServer.
Si null, aucun domaine n’est renseigné dans le cookie.
o.Authentication.CheckSessionCookieName = "idsrv.session";
Nom utilisé pour le cookie de session IdentityServer.
o.Authentication.CheckSessionCookieSameSiteMode =
SameSiteMode.None;
Mode du cookie de session. Par défaut, la valeur est SameSiteMode.None, ce qui en fait un cookie "tiers" qui doit être obligatoirement crypté.
o.Authentication.CookieAuthenticationScheme = null;
Scheme utilisé par le cookie d’authentification. Si rien n’est défini, c’est le scheme par défaut, de l’application, qui sera utilisé.
o.Authentication.CookieLifetime = TimeSpan.FromHours(10);
Durée de vie du cookie d’authentification. Applicable uniquement si le cookie handler de IdentityServer est utilisé.
o.Authentication.CookieSameSiteMode = SameSiteMode.None;
Mode du cookie d’authentification.
o.Authentication.CookieSlidingExpiration = false;
Durée de vie glissante du cookie d’authentification. Par défaut, la date d’expiration du cookie est fixe.
o.Authentication.CoordinateClientLifetimesWithUserSession = false;
Synchronise la durée de vie des tokens avec les sessions utilisateur. Si activé, ce paramètre purge tous les refresh tokens d’un utilisateur quand celui-ci se déconnecte. Ce qui le déconnecte...
Amorçage de la base de données
Identity server se sert d’une base de données pour persister toutes les informations de configuration vues dans ce chapitre. Afin de faciliter les choses, Identity Server fournit les implémentations nécessaires pour fonctionner avec Entity Framework Core. Les données sont ensuite enregistrées dans la base de données que vous avez choisie, Entity Framework Core faisant l’abstraction de cette base.
1. Méthode d’initialisation
Pour initialiser notre base de données, nous allons créer une classe Seed qui s’exécutera au démarrage de l’application :
public static class Seed
{
public static Task SeedDatabase(this WebApplication app)
{
return Task.CompletedTask;
}
}
Dans le fichier Program.cs, nous allons appeler la méthode SeedDatabase() juste après la construction de l’application :
WebApplication app = builder.Build();
await app.SeedDatabase();
......
App.Run();
Maintenant, remplissons la méthode Seed() pour inscrire en base de données les données minimales nécessaires pour fonctionner.
2. Migrations
Tout d’abord, les DbContext étant des services scopés, nous devons créer un scope car, au démarrage de l’application, nous ne sommes pas encore dans le scope d’une requête http.
IServiceScope scope = app.Services.CreateScope();
Ensuite, il faut appliquer les migrations aux différents DbContext. De cette manière, il n’y a plus besoin d’appeler la commande update-database depuis Visual Studio.
ApplicationDbContext applicationDbContext =
scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
await applicationDbContext.Database.MigrateAsync();
ConfigurationDbContext configurationDbContext =
scope.ServiceProvider.GetRequiredService<ConfigurationDbContext>();
await configurationDbContext.Database.MigrateAsync();
PersistedGrantDbContext persistedGrantDbContext =
scope.ServiceProvider.GetRequiredService<PersistedGrantDbContext>();
await persistedGrantDbContext.Database.MigrateAsync();
3. IdentityResources
Ensuite, nous allons créer un tableau de IdentityResource...