Blazor - Authentification par Cookie
Pour authentifier un utilisateur, Blazor Server utilise les mêmes composants que ceux d’ASP.NET Core.
Le principe est donc d’injecter le service services.AddAuthentication().AddCookie()
et d’appeler la méthode HttpContext.SignInAsync
en précissant les Claims
adéquats.
Mais le principal problème est que Blazor Server utilise SignalR pour communiquer entre le navigateur internet et le serveur. Ce qui empêche le transfert correct des cookies. Il est donc nécessaire de définir des WebAPIs et de les appeler en Http.
Pour aider à la compréhension du développement de ces différentes étapes, je vous ai enregistré une vidéo qui reprend la création d’un projet Blazor Server et l’intégration de toutes étapes pour sécuriser une page ou des composants.
Video - Comment créer un projet Blazor Server authentifié
Le code source complet est disponible ici.
1. Creation des WebAPI
Comme signalé dans l’introduction, Blazor Server ne peut pas envoyer le cookie via SignalR, son protocole de communication habituel pour échanger ses changements d’états entre le navigateur internet et le serveur.
Il est donc nécessaire de créer une WebAPI pour se connecter api/auth/signin (qui prend un objet Email/Password en argument) et une autre pour se déconnecter api/auth/signout.
Il est également possible de créer des API de type GET mais le login, et surtout le mot de passe (même hashé) sera envoyé dans l’URL de navigation. Personnellement, je préfère définir des API de type POST pour éviter cela.
- SignInPost construit l’ensemble des Claims qui identifient l’utilisateur, ainsi que les propriétés du cookie (durée de vie, persistance, …). Il reste à appeler la méthode HttpContext.SignInAsync fournie en standard par Microsoft pour générer un cookie encrypté.
- SignOutPost appelle la méthode HttpContext.SignOutAsync fournie également par Microsoft pour détruire le cookie.
Voir la documentation de Microsoft: Create an authentication cookie
[ApiController]
public class AuthController : ControllerBase
{
private static readonly AuthenticationProperties COOKIE_EXPIRES = new AuthenticationProperties()
{
ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(10),
IsPersistent = true,
};
[HttpPost]
[Route("api/auth/signin")]
public async Task<ActionResult> SignInPost(SigninData value)
{
var claims = new List<Claim>
{
new Claim(ClaimTypes.Email, value.Email),
new Claim(ClaimTypes.Name, value.Email),
new Claim(ClaimTypes.Role, "Administrator"),
};
var claimsIdentity = new ClaimsIdentity(claims,
CookieAuthenticationDefaults.AuthenticationScheme);
var authProperties = COOKIE_EXPIRES;
await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(claimsIdentity),
authProperties);
return this.Ok();
}
[HttpPost]
[Route("api/auth/signout")]
public async Task<ActionResult> SignOutPost()
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return this.Ok();
}
}
public class SigninData
{
public string Email { get; set; }
public string Password { get; set; }
}
2. Code JavaScript pour appeler les APIs
Cette seconde étape consiste a ajouter un script JS pour appeler en HTTPS (et pas en SignalR) les API que nous venons d’écrire.
Ce fichier auth.js
est placé dans le dossier wwwroot/js
pour être accessible par le moteur Blazor
lors de l’exécution de l’application. Les fonctions sont préfixées de export
pour permettre de les utiliser
en tant que JavaScript isolation modules.
-
SignIn est une méthode qui appelle l’api de connexion, en mode POST, avec un contenu sérialisé en JSON. Une fois une réponse positive reçue de l’API, le code redirige l’utilisateur vers une adresse précisée en argument.
-
SignOut est une méthode qui appelle l’api de déconnexion, en mode POST. Une fois une réponse positive reçue de l’API, le code redirige l’utilisateur vers une adresse précisée en argument.
export function SignIn(email, password, redirect) {
var url = "/api/auth/signin";
var xhr = new XMLHttpRequest();
// Initialization
xhr.open("POST", url);
xhr.setRequestHeader("Accept", "application/json");
xhr.setRequestHeader("Content-Type", "application/json");
// Catch response
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) // 4=DONE
{
console.log("Call '" + url + "'. Status " + xhr.status);
if (redirect)
location.replace(redirect);
}
};
// Data to send
var data = {
email: email,
password: password
};
// Call API
xhr.send(JSON.stringify(data));
}
export function SignOut(redirect) {
var url = "/api/auth/signout";
var xhr = new XMLHttpRequest();
// Initialization
xhr.open("POST", url);
xhr.setRequestHeader("Accept", "application/json");
xhr.setRequestHeader("Content-Type", "application/json");
// Catch response
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) // 4=DONE
{
console.log("Call '" + url + "'. Status " + xhr.status);
if (redirect)
location.replace(redirect);
}
};
// Call API
xhr.send();
}
3. Se connecter et se déconnecter
Maintenant que les WebAPIs et que le code JavaScript associé sont développés, nous pouvons les utiliser dans notre page.
Dans cet exemple, nous modifions la page Counter
pour y placer deux boutons (Login et Logout)
et pour appeler les deux méthodes JavaScript.
Pour cela, nous devons injecter le JSRunTime
.
La ligne var authModule = ...
se charge de télécharger le fichier JavaScript spécifié
et de le mettre en mémoire dans le composant pour pouvoir utiliser ses méthodes.
@page "/counter"
@inject IJSRuntime JSRunTime
<button @onclick="btnLogin_Click">Login</button>
<button @onclick="btnLogout_Click">Logout</button>
@code {
private async void btnLogin_Click()
{
var authModule = await JSRunTime.InvokeAsync<IJSObjectReference>("import", "./js/auth.js");
await authModule.InvokeVoidAsync("SignIn", "denis@voituron.net", "MyPassword", "/");
}
private async void btnLogout_Click()
{
var authModule = await JSRunTime.InvokeAsync<IJSObjectReference>("import", "./js/auth.js");
await authModule.InvokeVoidAsync("SignOut", "/");
}
}
4. Injection des services d’authentification
Il nous reste à injecter les services d’authentification par Cookie pour pouvoir l’utiliser dans notre application.
-
Dans le fichier
Startup.cs
, injecter les controlleurs pour les WebAPIs et le service d’authentification par Cookie. Préciser le nom du cookie de votre choix (ex. myauth) et la sécurité souhaitée. -
Dans le fichier
Startup.cs
, utiliser l’authentification (UseAuthentication) et les controlleurs (MapControllers).
// Startup.cs
public void ConfigureServices(IServiceCollection services)
{
// ... Existing services ...
services.AddControllers();
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.Cookie.Name = "myauth";
options.Cookie.SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Strict;
});
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
// ... Existing services ...
app.UseAuthentication();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
endpoints.MapBlazorHub();
endpoints.MapFallbackToPage("/_Host");
});
}
- Dans le fichier
App.razor
, adapter le comme suit :- Ajouter la balise
CascadingAuthenticationState
. - Modifier la balise
Found
pour retier laRouteView
par défaut et la remplacer par uneAuthorizeRouteView
qui contient le texte à afficher lors d’un refus d’autorisation et le texte à afficher lorsque la procédure de demande d’authorisation prend du temps (les pages Blazor s’affichent en asynchrone).
- Ajouter la balise
<!-- App.razor -->
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="@true">
<Found Context="routeData">
<!-- <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" /> -->
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
<NotAuthorized>
<h1>Sorry</h1>
<p>You're not authorized to reach this page.</p>
<p>You may need to log in as a different user.</p>
</NotAuthorized>
<Authorizing>
<h1>Authorization in progress</h1>
<p>Only visible while authorization is in progress.</p>
</Authorizing>
</AuthorizeRouteView>
</Found>
<NotFound>
<LayoutView Layout="@typeof(MainLayout)">
<p>Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
</CascadingAuthenticationState>
5. Sécurisation des pages et des composants
A. Dès maintenant sécuriser vos pages en ajoutant l’attribut [Authorize]
(éventuellement, en spécifiant des rôles d’accès).
Si l’utilisateur n’est pas connu, il recevra le message contenu dans la page App.razor
.
@page "/fetchdata"
@attribute [Authorize]
<h1>Weather forecast</h1>
B. Vous pouvez également sécuriser vos composants grâce à la balise <AuthorizeView>
.
<AuthorizeView>
<h3>Hello World @(context.User.Identity.Name)</h3>
</AuthorizeView>
6. (optionnel) Personnalisation de la validation
Si nécessaire, il est également possible de capturer les événements de validation des Cookies d’authentification. Il est ainsi très simple d’injecter un service d’accès aux données (ex. Factory dans cet exemple) pour se connecter à une base de données et garantir que l’utilisateur existe toujours correctement, avec les bons rôles d’accès.
- CookieAuthenticationEvents est une classe qui hérite et surcharge la class proposée par Microsoft.
public class CookieAuthenticationEvents : Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationEvents
{
//private readonly Factory _factory;
//public CookieAuthenticationEvents(Factory factory)
//{
// _factory = factory;
//}
public override async Task ValidatePrincipal(CookieValidatePrincipalContext context)
{
// Validate the Principal
var userPrincipal = context.Principal;
// If not valid, Sign out
if (userPrincipal.Identity.Name != "denis@voituron.net")
{
context.RejectPrincipal();
await context.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
}
}
}
- options.EventsType est une option pour sélectionne la classe de validation du cookie. Il est nécessaire d’injecter cette classe pour préciser à Blazor comment construire cet objet.
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.Cookie.Name = "myauth";
options.Cookie.SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Strict;
// Add this new line
options.EventsType = typeof(CookieAuthenticationEvents); // <---
});
// Add this new line
services.AddScoped<CookieAuthenticationEvents>(); // <---
Le code source complet est disponible ici.