English
Français

Blog of Denis VOITURON

for a better .NET world

Blazor - Authentification par Cookie

Posted on 2021-08-26

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.

Global Schema

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.

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.

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.

// 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");
    });
}
<!-- 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.

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);
        }
    }
}
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.

Langues

EnglishEnglish
FrenchFrançais

Suivez-moi

Articles récents