Nel precedente articolo ci siamo lasciati con l’impegno di implementare la pagina di Login, per la quale abbiamo bisogno di aggiungere al costruttore del controller Account la dipendenza dalla classe SignInManager e della classe Configuration:

private readonly UserManager<Athlete> _userManager;
private readonly ILogger<AccountsController> _logger;
private readonly IEmailSender _emailSender;
private readonly SignInManager<Athlete> _signInManager;
private readonly IConfiguration _configuration;

public AccountsController(
    ILogger<AccountsController> logger,
    UserManager<Athlete> userManager,
    IEmailSender emailSender, 
    SignInManager<Athlete> signInManager, 
    IConfiguration configuration)
{
    _logger = logger;
    _userManager = userManager;
    _emailSender = emailSender;
    _signInManager = signInManager;
    _configuration = configuration;
}

In questo modo possiamo andare a creare la action Login nello stesso controller:

[HttpPost("[action]")]
public async Task<IActionResult> Login([FromBody] LoginRequest loginRequest)
{
    LoginResponse response = new LoginResponse();
    try {
        var result = await _signInManager.PasswordSignInAsync(loginRequest.Email, loginRequest.Password, false, false);

        if (!result.Succeeded)
        {
            response.Errors = new List<string> { "Username and password are invalid." };
            return Ok(response);
        }

        var user = await _signInManager.UserManager.FindByEmailAsync(loginRequest.Email);
        var roles = await _signInManager.UserManager.GetRolesAsync(user);

        var claims = new List<Claim>();
        claims.Add(new Claim(ClaimTypes.Name, loginRequest.Email));

        foreach (var role in roles)
        {
            claims.Add(new Claim(ClaimTypes.Role, role));
        }

        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["Jwt:SecurityKey"]));
        var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
        var expiry = DateTime.Now.AddDays(Convert.ToInt32(_configuration["Jwt:ExpiryInDays"]));

        var token = new JwtSecurityToken(
            _configuration["Jwt:Issuer"],
            _configuration["Jwt:Audience"],
            claims,
            expires: expiry,
            signingCredentials: creds
        );
        response.Token = new JwtSecurityTokenHandler().WriteToken(token);
        response.IsSuccess = true;
        return Ok(response);
    }
    catch (Exception ex)
    {
        _logger.LogInformation($"Login error: {ex.Message} - Email: {loginRequest.Email}");
        response.Errors = new List<string> { ex.Message };
        return Ok(response);
    }
}

Il metodo controlla che i dati immessi siano corretti e genera il token. Come è possibile vedere dal codice, nel token vengono inseriti anche i ruoli relativi all’utente, ma per poterli utilizzare è necessario specificare nel file Startup.cs l’aggiunta di essi:

services.AddDefaultIdentity<Athlete>(options => {
    options.Password.RequireNonAlphanumeric = false;
    options.Password.RequireDigit = false;
    options.Password.RequiredLength = 8;
})
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>();

In questo momento non c’è nessun ruolo configurato, ma successivamente vedremo come questo concetto ci permetta di diversificare gli utenti dell’applicazione. Se ispezioniamo la richiesta di login, dovremmo visualizzare qualcosa di simile:

Token restituito dal login

Ok, abbiamo il token, adesso va gestito.

Autenticazione in Blazor Web Assembly

Nella documentazione ufficiale ci sono molti esempi di integrazione di autenticazione ed autorizzazione ma, come detto nel primo articolo di questa serie, creremo il nostro provider personalizzato.

Prima di tutto creiamo una interfaccia chiamata ILoginService, che salveremo nella cartella Auth del progetto Client:

public interface ILoginService
{
    Task Login(string token);
    Task Logout();
}

Abbiamo bisogno di salvare il token nello storage del browser, quindi installiamo un pacchetto NuGet chiamato Blazored.LocalStorage che ci consente di leggere e scrivere nel local storage in modo immediato:

Local Storage Nuget Package

Installiamo anche:

  • Microsoft.AspNetCore.Components.WebAssembly.Authentication
  • System.IdentityModel.Tokens.Jwt

Per utilizzare BlazoredLocalStorage è necessario aggiungere nel Program.cs del progetto Client il service fornito con l’istruzione builder.Services.AddBlazoredLocalStorage().

Passiamo a creare il nostro provider di autenticazione che chiameremo CustomAuthStateProvider.cs, che verrà salvato nella cartella Auth del progetto Client:

public class CustomAuthStateProvider : AuthenticationStateProvider, ILoginService
{
    private readonly ILocalStorageService _localStorage;
    private readonly HttpClient _httpClient;
    private static readonly string _tokenkey = "TOKENKEY";
    private AuthenticationState _anonymous => new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));

    public CustomAuthStateProvider(ILocalStorageService localStorage, HttpClient httpClient)
    {
        this._localStorage = localStorage;
        this._httpClient = httpClient;
    }

    public async override Task<AuthenticationState> GetAuthenticationStateAsync()
    {
        var token = await _localStorage.GetItemAsync<string>(_tokenkey);
        if (!tokenStatus(token))
        {
            _httpClient.DefaultRequestHeaders.Authorization = null;
            await _localStorage.RemoveItemAsync(_tokenkey);
            return _anonymous;
        }
        return buildAuthenticationState(token);
    }

    public async Task Login(string token)
    {
        await _localStorage.SetItemAsync(_tokenkey, token);
        var authState = buildAuthenticationState(token);
        NotifyAuthenticationStateChanged(Task.FromResult(authState));
    }

    public async Task Logout()
    {
        _httpClient.DefaultRequestHeaders.Authorization = null;
        await _localStorage.RemoveItemAsync(_tokenkey);
        NotifyAuthenticationStateChanged(Task.FromResult(_anonymous));
    }

    private AuthenticationState buildAuthenticationState(string token)
    {
        _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("bearer", token);
        return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(claimsFromJwt(token), "jwt")));
    }

    private bool tokenStatus(string token)
    {
        try
        {
            if (String.IsNullOrEmpty(token))
                return false;

            var jwthandler = new JwtSecurityTokenHandler();
            var jwttoken = jwthandler.ReadJwtToken(token);
            return jwttoken.ValidTo > DateTime.UtcNow;
        }
        catch
        {
            return false;
        }
    }

    private IEnumerable<Claim> claimsFromJwt(string token)
    {
        try
        {
            if (String.IsNullOrEmpty(token))
                return null;

            var jwthandler = new JwtSecurityTokenHandler();
            var jwttoken = jwthandler.ReadJwtToken(token);
            return jwttoken.Claims;
        }
        catch
        {
            return null;
        }
    }
}

Inoltre impostiamo il servizio nel file Program.cs:

builder.Services.AddAuthorizationCore();
builder.Services.AddScoped<CustomAuthStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthStateProvider>(
    provider => provider.GetRequiredService<CustomAuthStateProvider>());
builder.Services.AddScoped<ILoginService, CustomAuthStateProvider>(
provider => provider.GetRequiredService<CustomAuthStateProvider>());
builder.Services.AddOptions();

Adesso modifichiamo la pagina di Login per utilizzare il nostro provider:

loginResponse = await Http.PostJsonAsync<LoginResponse>("api/accounts/login", model);
if (loginResponse.IsSuccess)
{
    await loginService.Login(loginResponse.Token);
    navManager.NavigateTo("");
}

Modifichiamo il routing nel file App.razor per utilizzare l’AuthorizeRouteView:

<Router AppAssembly="@typeof(Program).Assembly">
    <Found Context="routeData">
        <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
            <NotAuthorized>
                <h5>Non autorizzato</h5>
            </NotAuthorized>
            <Authorizing>
                <h5>In autorizzazione</h5>
            </Authorizing>
        </AuthorizeRouteView>
    </Found>
    <NotFound>
        <CascadingAuthenticationState>
            <LayoutView Layout="@typeof(MainLayout)">
                <p>Sorry, there's nothing at this address.</p>
            </LayoutView>
        </CascadingAuthenticationState>
    </NotFound>
</Router>

A questo punto possiamo gestire nella nostra Index.razor, che si trova in Pages, il blocco da mostrare in caso l’utente sia autorizzato e quello nel caso in cui non risulti esserlo:

<AuthorizeView>
    <Authorized>
        Hello, @context.User.Identity.Name!
        <a href="LogOut">Log out</a>
    </Authorized>
    <NotAuthorized>
        <a href="Register">Register</a>
        <a href="Login">Log in</a>
    </NotAuthorized>
</AuthorizeView>

Facciamo partire l’applicazione e dopo aver fatto la login, se tutto funziona correttamente, visualizzeremo questa schermata:

Pagina di benvenuto dopo login

Possiamo vedere anche il token salvato sul browser:

Token salvato nel browser

Perfetto, siamo dentro! Vediamo adesso come uscire.

Creiamo la pagina di Logout dentro la cartella Account (negli articoli precedenti si chiamava Auth ma l’ho rinominata per rendere tutto omogeneo) il cui contenuto sarà il seguente:

@page "/logout"
@inject ILoginService loginService
@inject NavigationManager navigationManager
@layout MainLayout

<text>Bye!</text>
@code {
    protected async override Task OnInitializedAsync()
    {
        await loginService.Logout();
        navigationManager.NavigateTo("");
    }
}

Avviamo l’applicazione e vediamo se il logout funziona:

Logout

Ottimo, siamo usciti dall’applicazione! Ispezionando il local storage vedrete che anche il token è stato eliminato.