Entriamo nel vivo della fase di registrazione dell’utente, completando il processo di creazione e di convalida dell’account. Per fare ciò andiamo a creare il controller che si occuperà della registrazione e di altre funzionalità che riguardano l’utente.

Email service

Qualsiasi applicazione web necessita di inviare e-mail quindi, prima di andare a scrivere la logica di gestione dell’utente, occupiamoci di fare il setup del nostro servizio e-mail. Per questo servizio ho creato un’interfaccia (nella cartella Interfaces) che riporta il seguente codice:

public interface IEmailSender
{
    List<MailAddress> GetEmailList(string emails);
    bool IsValidEmail(string email);
    Task<bool> SendMail(string To, string Subject, string Body, string Allegati = null, string Cc = null);
}

Inoltre, dato che carico la configurazione per l’SMTP dal file appsettings.json, ho pensato di creato una classe di opzioni denominata EmailSenderOption, il cui contenuto è il seguente:

public class EmailSenderOption
{
    public string From { get; set; }
    public bool IsAuthenticate { get; set; }
    public string Username { get; set; }
    public string Password { get; set; }
    public string Host { get; set; }
    public bool SSL { get; set; }
    public int Port { get; set; }
    public bool IsBodyHTML { get; set; } = true;
    public string Bcc { get; set; }
}

Dentro il file appsettings.json ho aggiunto questa nuova configurazione a quelle presenti:

"EmailSenderOption": {
    "From": "email@mittente.it",
    "IsAuthenticate": true,
    "Username": "email@mittente.it",
    "Password": "passwordSegretissima205920!",
    "Host": "mail.server.it",
    "SSL": true,
    "Port": 25,
    "IsBodyHTML": true,
    "Bcc": null
}

Nel progetto “Server” quindi abbiamo questa nuova struttura:

Struttura progetto aggiornata

Non resta che aggiungere al file Startup.cs il nuovo servizio:

services.Configure<EmailSenderOption>(Configuration.GetSection(nameof(EmailSenderOption)));
services.AddSingleton<IEmailSender, EmailSenderService>();

Account controller

Cliccando con il tasto destro della cartella denominata Controllers presente sul progetto Server, possiamo selezionare la voce Aggiungi e successivamente Nuovo elemento…. Selezioniamo poi API Controller Class – Empty e digitiamo il nome del nostro controller, AccountsController.cs:

Struttura progetto aggiornata

Nel nuovo controller andiamo ad iniettare tramite dependency injection ILogger e IEmailSender, insieme a UserManager, il servizio che ci fornisce ASP.NET Identity per la gestione degli account. Creeremo l’action Register che prende in input il modello RegisterRequest e restituisce l’oggetto RegisterResponse, che contiene tutto il necessario per capire se la richiesta è andata a buon fine:

[HttpPost("[action]")]
public async Task<IActionResult> Register([FromBody]RegisterRequest registerRequest)
{
    try
    {
        if (ModelState.IsValid)
        {
            var user = new Athlete
            {
                UserName = registerRequest.Email,
                Email = registerRequest.Email,
                LastName = registerRequest.LastName,
                FirstName = registerRequest.FirstName,
                Birthday = registerRequest.Birthday.Value,
                Sex = registerRequest.Sex.Value.Equals(1) ? 'M' : 'W'
            };

            var result = await _userManager.CreateAsync(user, registerRequest.Password);
            if (!result.Succeeded)
            {
                RegisterResponse registerResponse = new RegisterResponse
                {
                    IsSuccess = false,
                    EmailExist = result.Errors.FirstOrDefault(x => x.Code.Equals("DuplicateUserName")) != null,
                    Errors = result.Errors.Select(x => x.Description),
                };
                return Ok(registerResponse);
            }

            _logger.LogInformation("User created a new account with password.");

            
            var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);

            var urlConfirmation = $"{Request.Scheme}://{Request.Host}/account/emailconfirmation/?userid={HttpUtility.UrlEncode(user.Id)}&code={HttpUtility.UrlEncode(code)}"; 
            await _emailSender.SendMail(user.Email, "Email confirmation", $"Please confirm your account by <a href='{urlConfirmation}'>clicking here</a>");
            
            return Ok(new RegisterResponse { IsSuccess = true });
        }
        else
        {
            return Ok(new RegisterResponse { IsSuccess = false, Errors = new List<string> { "modello non valido" } });
        }
    }
    catch (Exception ex)
    {
        _logger.LogInformation($"User register error: {ex.Message}");
        return Ok(new RegisterResponse { IsSuccess = false, Errors = new List<string> { ex.Message } });
    }
}

Cosa avviene se inseriamo dei dati non corretti? Come abbiamo visto nel precedente articolo, il modello è validato in base alle Data Annotation specificate nella classe RegisterRequest. Inoltre adesso se a ASP.NET Identity non piacciono i dati che abbiamo inserito, valorizzerà una lista di errori che verranno visualizzati nella form di registrazione per fornire un output chiaro all’utente:

Validazione utente durante la registrazione

Cosa accade invece se i dati sono corretti e la creazione dell’utente va a buon fine? Sul database ci sarà la entry contenente il nuovo utente ma latp frontend dobbiamo ancora gestire il risultato.

Utente creato sul database

E’ importante notare che il campo EmailConfirmed sul database è “0”, questo perché una volta creato l’utente è necessario (impostazione di default di ASP.NET Identity) confermare l’indirizzo email. Se non ci sono stati intoppi e se il servizio email è operativo, controllate la casella e-mail perché dovreste aver ricevuto un messagio con il testo: “Please confirm your account by clicking here”. Cliccando però non succede nulla perché non abbiamo ancora gestito la richiesta nel front-end.

Conferma e-mail

Andiamo a creare sul progetto client la pagina di conferma dell’indirizzo e-mail, che chiamerò con una fantasia spropositata EmailConfirmation.razor. Ecco il suo contenuto:

@page "/account/emailconfirmation"
@layout AuthLayout
@inject HttpClient Http
@inject NavigationManager navManager
@using CompTrain.Shared.Models.Account;

<div class="row">
    <div class="col-12">
            <div class="alert alert-warning" role="alert">
                <h4 class="alert-heading">Ops..</h4>
                <p>Qualcosa &egrave; andato storto, controlla l'indirizzo che ti è stato inviato per email.</p>
                <hr>
                <p class="mb-0">L'indirizzo email non è confermato e quindi non potrai effettuare la login.</p>
            </div>
    </div>
</div>
@code {
    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        EmailConfirmationRequest emailConfirmationRequest = new EmailConfirmationRequest();

        var uri = navManager.ToAbsoluteUri(navManager.Uri);

        if (QueryHelpers.ParseQuery(uri.Query).TryGetValue("userid", out var userid) && QueryHelpers.ParseQuery(uri.Query).TryGetValue("code", out var code))
        {
            emailConfirmationRequest.UserId = userid.First();
            emailConfirmationRequest.Code = code.First();
        }

        if (!String.IsNullOrEmpty(emailConfirmationRequest.UserId) && !String.IsNullOrEmpty(emailConfirmationRequest.Code))
        {
            bool IsValid = await Http.PostJsonAsync<bool>("api/accounts/emailconfirmation", emailConfirmationRequest);
            if (IsValid)
            {
                navManager.NavigateTo("/account/login");
            }
        }
    }
}

Questa pagina ha bisogno di un pacchetto NuGet denominato Microsoft.AspNetCore.WebUtilities, che utilizziamo per parsare la query string. Ho utilizzato anche la classe EmailConfirmationRequest, creata nel progetto Shared, che conterrà i due valori che arrivano dalla URL:

public class EmailConfirmationRequest
{
    public string UserId { get; set; }
    public string Code { get; set; }
}

Nel controller AccountController dobbiamo aggiungere il metodo che verifica la validità dell’utente:

[HttpPost("[action]")]
public async Task<IActionResult> EmailConfirmation([FromBody]EmailConfirmationRequest confirmationRequest)
{
    try
    {
        var user = await _userManager.FindByIdAsync(confirmationRequest.UserId);
        if (user == null)
            throw new Exception("User not found");

        var result = await _userManager.ConfirmEmailAsync(user, confirmationRequest.Code);
        return Ok(result.Succeeded);
    } catch(Exception ex)
    {
        _logger.LogInformation($"Email confirmation error: {ex.Message} - UserID: {confirmationRequest.UserId} - Code: {confirmationRequest.Code}");
        return Ok(false);
    }
}

Non ho creato una classe Response perché il tipo di dato è semplicemente un boolean che indica se tutto è filato liscio. Come avrete potuto notare, la pagina EmailConfirmation visualizza il messaggio di avviso e solo se la conferma della e-mail va a buon fine, c’è il redirect alla schermata di login (che dobbiamo ancora realizzare).

Avviso e-mail utente non confermata

Se controlliamo il database vedremo che l’indirizzo e-mail è stato confermato:

E-mail utente confermata sul database

Componente di notifica invio email

Nell’articolo precedente abbiamo lasciato la pagina Register.razor priva di qualunque feedback per l’utente quando la registrazione va a buon fine:

public async Task RegisterUser()
{
    registerResponse = await Http.PostJsonAsync<RegisterResponse>("api/accounts/register", model);
    if (registerResponse.IsSuccess)
    {

    }
}

Per sopperire a questa mancanza, ho creato il componente EmailSent.razor (nella cartella Shared del progetto Client) che non fa altro che mostrare un avviso per invitare l’utente a controllare la propria posta. Ecco il contenuto:

<div class="alert alert-success" role="alert">
    <h4 class="alert-heading">Perfetto!</h4>
    <p>Controlla nella tua posta elettronica perché ti abbiamo appena inviato una email.</p>
    @if (!String.IsNullOrEmpty(Email))
    {
        <hr>
        <p class="mb-0">Il tuo indirizzo ci risulta essere <strong>@Email</strong></p>
    }
</div>
@code {
    [Parameter]
    public string Email { get; set; }
}

Ed il risultato è questo:

Messaggio di conferma e invito a verificare la posta

Riutilizzeremo questo componente anche quando invieremo la mail per il recupero password o il cambio email. Il codice di Register.razor diventa quindi:

@page "/account/register"
@layout AuthLayout
@inject HttpClient Http
@using CompTrain.Shared.Models.Account;
@inject NavigationManager navManager

<div class="row">
    <div class="col-12">
        @if ((registerResponse?.IsSuccess).GetValueOrDefault() == false)
        {
            <h2>Create new account</h2>
            <EditForm Model="model" OnValidSubmit="RegisterUser">
                <RadzenCard>
                    <label>First Name</label>
                    <RadzenTextBox @bind-Value="model.FirstName"></RadzenTextBox>

                    <label>Last Name</label>
                    <RadzenTextBox @bind-Value="model.LastName"></RadzenTextBox>

                    <label>Birthday</label>
                    <RadzenDatePicker @bind-Value="model.Birthday" DateFormat="dd/MM/yyyy" />

                    <label>Sex</label>
                    <RadzenRadioButtonList @bind-Value="model.Sex" TValue="int?">
                        <Items>
                            <RadzenRadioButtonListItem Text="Man" Value="1" TValue="int?" />
                            <RadzenRadioButtonListItem Text="Woman" Value="2" TValue="int?" />
                        </Items>
                    </RadzenRadioButtonList>


                    <label>Email</label>
                    <RadzenTextBox @bind-Value="model.Email"></RadzenTextBox>

                    <label>Password</label>
                    <RadzenPassword @bind-Value="model.Password"></RadzenPassword>

                    <label>Confirm Password</label>
                    <RadzenPassword @bind-Value="model.ConfirmPassword"></RadzenPassword>

                    <hr />
                    <DataAnnotationsValidator />
                    <ValidationSummary />


                    <Alert Title="Attenzione" ErrorList="registerResponse?.Errors" />
                    @if ((registerResponse?.EmailExist).GetValueOrDefault())
                    {
                        <RadzenButton ButtonType="Radzen.ButtonType.Button" Click="@((args) => GoToPage(args, "forgotpassword"))" Text="Reset password?" Icon="lock_open" ButtonStyle="Radzen.ButtonStyle.Secondary" class="btn-block btn-sm mr-2"></RadzenButton>
                        <RadzenButton ButtonType="Radzen.ButtonType.Button"Click="@((args) => GoToPage(args, "login"))" Text="Login" Icon="account_circle" ButtonStyle="Radzen.ButtonStyle.Info" class="btn-block btn-sm mr-2"></RadzenButton>
                        <hr />
                    }

                    <RadzenButton ButtonType="Radzen.ButtonType.Submit" Text="Register" Icon="verified_user" ButtonStyle="Radzen.ButtonStyle.Primary" class="btn-block mr-2"></RadzenButton>

                </RadzenCard>
            </EditForm>
        }
        else
        {
            <EmailSent Email="@model.Email" />
        }
    </div>
</div>
@code {
    RegisterRequest model = new RegisterRequest();
    RegisterResponse registerResponse = null;

    public async Task RegisterUser()
    {
        registerResponse = await Http.PostJsonAsync<RegisterResponse>("api/accounts/register", model);
    }

    void GoToPage(MouseEventArgs args, string page)
    {
        navManager.NavigateTo($"/account/{page}");
    }
}

Recupero password

E’ indispensabile per qualsiasi sistema di autenticazione fornire la funzionalità di recupero password. Andiamo quindi ad implementare la pagina che si occuperà di gestire la richiesta di reset password. La nuova pagina l’ho chiamata ForgotPassword.razor ed il suo contenuto è il seguente:

@page "/account/forgotpassword"
@layout AuthLayout
@inject HttpClient Http
@using CompTrain.Shared.Models.Account;
@inject NavigationManager navManager

<div class="row">
    <div class="col-12">
        @if ((forgotPasswordResponse?.IsSuccess).GetValueOrDefault() == false)
        {
            <h2>Forgot password</h2>
            <EditForm Model="model" OnValidSubmit="ResetPassword">
                <RadzenCard>
                    <label>Email</label>
                    <RadzenTextBox @bind-Value="model.Email"></RadzenTextBox>
                    <hr />
                    <DataAnnotationsValidator />
                    <ValidationSummary />
                    <Alert Title="Attenzione" ErrorList="forgotPasswordResponse?.Errors" />
                    <RadzenButton ButtonType="Radzen.ButtonType.Submit" Text="Reset Password" Icon="track_changes" ButtonStyle="Radzen.ButtonStyle.Primary" class="btn-block mr-2"></RadzenButton>
                </RadzenCard>
            </EditForm>
        }
        else
        {
            <EmailSent Email="@model.Email" />
        }
    </div>
</div>
@code {
    ForgotPasswordRequest model = new ForgotPasswordRequest();
    ForgotPasswordResponse forgotPasswordResponse = null;

    public async Task ResetPassword()
    {
        forgotPasswordResponse = await Http.PostJsonAsync<ForgotPasswordResponse>("api/accounts/forgotpassword", model);
    }
}

Ecco come si presenta la pagina:

Pagina recupero password

Andiamo quindi ad aggiungere le due classi usate per la Request e la Response:

public class ForgotPasswordRequest
{
    [Required]
    [StringLength(50)]
    [EmailAddress]
    public string Email { get; set; }
}

public class ForgotPasswordResponse
{
    public bool IsSuccess { get; set; }
    public IEnumerable<string> Errors { get; set; }
}

Entrambi i file sono stati creati nel progetto Shared:

Struttura progetto con i nuovi files

E’ necessario aggiungere anche il metodo sul controller Accounts:

[HttpPost("[action]")]
public async Task<IActionResult> ForgotPassword([FromBody]ForgotPasswordRequest forgotPasswordRequest)
{
    ForgotPasswordResponse response = new ForgotPasswordResponse();
    try
    {
        var user = await _userManager.FindByEmailAsync(forgotPasswordRequest.Email);
        if (user == null || !(await _userManager.IsEmailConfirmedAsync(user)))
        {
            response.Errors = new List<string> { "User not found or not confirmed email" };
        }
        else
        {
            
            var code = await _userManager.GeneratePasswordResetTokenAsync(user);
            var urlConfirmation = $"{Request.Scheme}://{Request.Host}/account/changepassword/?code={HttpUtility.UrlEncode(code)}";
            await _emailSender.SendMail(user.Email, "Reset password", $"Please reset your password by <a href='{urlConfirmation}'>clicking here</a>");
            response.IsSuccess = true;
        }
        return Ok(response);

    }
    catch (Exception ex)
    {
        _logger.LogInformation($"Forgot password error: {ex.Message} - Email: {forgotPasswordRequest.Email}");
        response.Errors = new List<string> { ex.Message };
        return Ok(response);
    }
}

Se adesso proviamo a fare il reset della password dovrebbe arrivarci una e-mail con un link e quindi dobbiamo gestire la pagina che abbiamo predisposto nel link e che ho chiamato ChangePassword.razor che si trova nel progetto Shared. Il suo contenuto è il seguente:

@page "/account/changepassword"
@layout AuthLayout
@inject HttpClient Http
@using CompTrain.Shared.Models.Account;
@inject NavigationManager navManager

<div class="row">
    <div class="col-12">
            <h2>Change password</h2>
            <EditForm Model="model" OnValidSubmit="Change">
                <RadzenCard>
                    <label>Email</label>
                    <RadzenTextBox @bind-Value="model.Email"></RadzenTextBox>

                    <label>Password</label>
                    <RadzenPassword @bind-Value="model.Password"></RadzenPassword>

                    <label>Confirm Password</label>
                    <RadzenPassword @bind-Value="model.ConfirmPassword"></RadzenPassword>
                    <hr />
                    <DataAnnotationsValidator />
                    <ValidationSummary />
                    <Alert Title="Attenzione" ErrorList="changePasswordResponse?.Errors" />
                    <RadzenButton ButtonType="Radzen.ButtonType.Submit" Text="Reset Password" Icon="track_changes" ButtonStyle="Radzen.ButtonStyle.Primary" class="btn-block mr-2"></RadzenButton>
                </RadzenCard>
            </EditForm>
    </div>
</div>
@code {
    ChangePasswordRequest model = new ChangePasswordRequest();
    ChangePasswordResponse changePasswordResponse = null;

    protected override void OnAfterRender(bool firstRender)
    {

        var uri = navManager.ToAbsoluteUri(navManager.Uri);

        if (QueryHelpers.ParseQuery(uri.Query).TryGetValue("code", out var code))
        {
            model.Code = code.First();
        }
    }

    public async Task Change()
    {

        changePasswordResponse = await Http.PostJsonAsync<ChangePasswordResponse>("api/accounts/changepassword", model);
        if (changePasswordResponse.IsSuccess)
        {
            navManager.NavigateTo("/account/login");
        }
    }
}

La pagina visualizzata sarà la seguente:

Pagina cambio password

Se URL ed dati inseriti sono corretti si verrà reindirizzati alla login.

Login

Abbiamo tutto il necessario per creare la pagina di login quindi creiamo il nuovo file razor chiamato appunto Login.razor, che conterrà il seguente codice:

@page "/account/login"
@layout AuthLayout
@inject HttpClient Http
@using CompTrain.Shared.Models.Account;
@inject NavigationManager navManager

<div class="row">
    <div class="col-12">
        <h2>Login</h2>
        <EditForm Model="model" OnValidSubmit="UserLogin">
            <RadzenCard>
                <label>Email</label>
                <RadzenTextBox @bind-Value="model.Email"></RadzenTextBox>

                <label>Password</label>
                <RadzenPassword @bind-Value="model.Password"></RadzenPassword>

                <hr />
                <DataAnnotationsValidator />
                <ValidationSummary />


                <Alert Title="Attenzione" ErrorList="loginResponse?.Errors" />

                <RadzenButton ButtonType="Radzen.ButtonType.Submit" Text="Login" Icon="account_circle" ButtonStyle="Radzen.ButtonStyle.Info" class="btn-block mr-2"></RadzenButton>
                <hr />
                <RadzenButton ButtonType="Radzen.ButtonType.Button" Click="@((args) => GoToPage(args, "forgotpassword"))" Text="Reset password?" Icon="lock_open" ButtonStyle="Radzen.ButtonStyle.Secondary" class="btn-block btn-sm mr-2"></RadzenButton>
                <RadzenButton ButtonType="Radzen.ButtonType.Button" Click="@((args) => GoToPage(args, "register"))" Text="Register" Icon="verified_user" ButtonStyle="Radzen.ButtonStyle.Primary" class="btn-block btn-sm mr-2"></RadzenButton>
            </RadzenCard>
        </EditForm>
    </div>
</div>
@code {
    LoginRequest model = new LoginRequest();
    LoginResponse loginResponse = null;

    public async Task UserLogin()
    {
        loginResponse = await Http.PostJsonAsync<LoginResponse>("api/accounts/login", model);
        if (loginResponse.IsSuccess)
        {
            navManager.NavigateTo("");
        }
    }

    void GoToPage(MouseEventArgs args, string page)
    {
        navManager.NavigateTo($"/account/{page}");
    }
}

L’aspetto grafico è questo:

Pagina login

Anche qui sono necessarie le due classi di Request e Response:

public class LoginRequest
{
    [Required]
    [StringLength(50)]
    [EmailAddress]
    public string Email { get; set; }

    [Required]
    [StringLength(16, ErrorMessage = "{0} length must be between {2} and {1}.", MinimumLength = 8)]
    public string Password { get; set; }
}

public class LoginResponse
{
    public bool IsSuccess { get; set; }
    public IEnumerable<string> Errors { get; set; }
    public string Token { get; set; }
}

Nel prossimo articolo vedremo come effettuare il login e quindi gestire l’autenticazione.