8/21/2022 Admin

Customizing The Blazor Login Process With External Authentication Providers


You can easily implement external authentication providers to allow the users of your Blazor application to log in with Facebook, Google, Microsoft, Twitter, and many others. However, you have to write code to associate an external login to an existing login. You can implement several methods such as sending a code in an email.

The method, covered here, will ask the user to enter the password of the existing account to make the association.

 

image

After clicking the Log In link to access the login screen, the user is presented with the option to log in using one or more external authentication providers.

Clicking the button for the external authentication provider, will direct the user to the authentication provider, to be authenticated.

 

image

After authenticating with the external authentication provider, the user is returned to the application.

If the user already has an account in the application they will be logged in. If the user does not have an account in the application, they will be presented with the Register page.

The email of the external account is displayed (they will not be allowed to change it) and they will be presented with the Register button that will create the account.

 

image

If the email address is already being used by an existing account, the user is informed of this, and they are prompted to enter the password for the existing account to associate this external login with that account.

 

image

If they do not enter the correct password, they are prompted again.

 

image

After entering the correct password, they are logged in, and the external login is associated with the existing account.

The user can now log in directly using a username and password, or log into the same account using the external authentication provider.

 

image

Note: When an account is created only using an external authentication provider, it will not have a password in the database. If a user attempts to associate an external provider to that account, (for example to associate a Facebook login to an account that was created using a Google login), they will see an error message.

 

image

To resolve this, the user simply needs to log in, using the working external authentication provider, and click on their username.

 

image

On the manage account settings page, they can select the Password menu option, to be presented with the form that will allow them to set a password for the account.

 

Creating The Application

image

We start with the application from the tutorial: Creating A Step-By-Step End-To-End Database Server-Side Blazor Application (Note: you can download the completed application from the Downloads page on this site).

Note: For added security, in the Program.cs file, it is suggested you set: options.SignIn.RequireConfirmedAccount = true and enable email confirmation (see: https://docs.microsoft.com/en-us/aspnet/core/security/authentication/accconfirm?view=aspnetcore-6.0&tabs=visual-studio#register-confirm-email-and-reset-password).

 

image

We can run the application and click the Register link.

 

image

We can create an account.

 

image

We will then be logged in.

 

image

If we look in the database, we see that an account has been created with a password.

 

Set Up An External Authentication Provider

image

We will now set up an external authentication provider by following the directions here: Google external login setup in ASP.NET Core.

 

image

This requires us to install the Microsoft.AspNetCore.Authentication.Google NuGet package.

 

image

It also requires us to add code like this to the appsettings.json file:

 

  "Authentication": {
    "Google": {
      "ClientId": "{{ Google Client Id }}",
      "ClientSecret": "{{ Google Client Secret }}"
    }
  },

 

image

Finally, it requires us to add the following code to the Program.cs file:

 

builder.Services.AddAuthentication().AddGoogle(googleOptions =>
{
    googleOptions.ClientId = 
    builder.Configuration.GetValue<string>("Authentication:Google:ClientId");
    
    googleOptions.ClientSecret = 
    builder.Configuration.GetValue<string>("Authentication:Google:ClientSecret");
});

 

image

Now when we run the application and click the Log in link we have the option to log in using an external authentication provider.

 

image

After logging into the external authentication provider, we are redirected back to the application and presented with the page to create an account.

 

image

If we use an email address that is already used by another account, we are unable to link this external login to the existing account.

 

Customize The Process

image

The first step to customize the process is to right-click on the project node and select Add then New Scaffolded Item…

Note: If you don’t see this option, Install the Microsoft.VisualStudio.Web.CodeGeneration.Design NuGet package.

 

image

Select Identity then Add.

 

image

Wait for the initializations…

 

image

Select Account\ExternalLogin, the Data context class, and press the Add button.

 

image

The ExternalLogin pages will be created.

Currently they will provide the exact same functionality as before, however, now we can customize them.

Replace all the code with the following code:

ExternalLogin.cshtml

 

@page
@model ExternalLoginModel
@{
    ViewData["Title"] = "Register";
}
<h1>@ViewData["Title"]</h1>
<h2 id="external-login-title">Associate your @Model.ProviderDisplayName account.</h2>
<hr />
@if (Convert.ToString(ViewData["AssociateExistingAccount"]) == "false")
{
    <p id="external-login-description" class="text-info">
        You've successfully authenticated with <strong>@Model.ProviderDisplayName</strong>.
        Click the Register button to finish logging in.
    </p>
    <div class="row">
        <div class="col-md-4">
            <form asp-page-handler="Confirmation" asp-route-returnUrl="@Model.ReturnUrl"
              method="post">
                <div asp-validation-summary="ModelOnly" class="text-danger"></div>
                <div class="form-floating">
                    <input asp-for="Input.Email" class="form-control" readonly="true"
                       autocomplete="email" />
                    <label asp-for="Input.Email" class="form-label"></label>
                    <span asp-validation-for="Input.Email" class="text-danger"></span>
                </div>
                <button type="submit" class="w-100 btn btn-lg btn-primary">Register</button>
            </form>
        </div>
    </div>
}
@section Scripts {
    <partial name="_ValidationScriptsPartial" />
}

 

 

ExternalLogin.cshtml.cs

 

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using System;
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.UI.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging;
namespace EndToEnd.Areas.Identity.Pages.Account
{
    [AllowAnonymous]
    public class ExternalLoginModel : PageModel
    {
        private readonly SignInManager<IdentityUser> _signInManager;
        private readonly UserManager<IdentityUser> _userManager;
        private readonly IUserStore<IdentityUser> _userStore;
        private readonly IUserEmailStore<IdentityUser> _emailStore;
        private readonly IEmailSender _emailSender;
        private readonly ILogger<ExternalLoginModel> _logger;
        public ExternalLoginModel(
            SignInManager<IdentityUser> signInManager,
            UserManager<IdentityUser> userManager,
            IUserStore<IdentityUser> userStore,
            ILogger<ExternalLoginModel> logger,
            IEmailSender emailSender)
        {
            _signInManager = signInManager;
            _userManager = userManager;
            _userStore = userStore;
            _emailStore = GetEmailStore();
            _logger = logger;
            _emailSender = emailSender;
        }
        [BindProperty]
        public InputModel Input { get; set; }
        public string ProviderDisplayName { get; set; }
        public string ReturnUrl { get; set; }
        [TempData]
        public string ErrorMessage { get; set; }
        [ViewData]
        public string AssociateExistingAccount { get; set; } = "false";
        public class InputModel
        {
            [Required]
            [EmailAddress]
            public string Email { get; set; }
            [DataType(DataType.Password)]
            public string Password { get; set; }
        }
        public IActionResult OnGet() => RedirectToPage("./Login");
        public IActionResult OnPost(string provider, string returnUrl = null)
        {
            // Request a redirect to the external login provider.
            var redirectUrl =
                Url.Page("./ExternalLogin", pageHandler: "Callback", values: new { returnUrl });
            var properties =
                _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);
            return new ChallengeResult(provider, properties);
        }
        public async Task<IActionResult> OnGetCallbackAsync(
            string returnUrl = null, string remoteError = null)
        {
            returnUrl = returnUrl ?? Url.Content("~/");
            if (remoteError != null)
            {
                ErrorMessage = $"Error from external provider: {remoteError}";
                return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
            }
            var info = await _signInManager.GetExternalLoginInfoAsync();
            if (info == null)
            {
                ErrorMessage = "Error loading external login information.";
                return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
            }
            // Sign in the user with this external login provider if the user already has a login.
            var result = await _signInManager.ExternalLoginSignInAsync(
                info.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor: true);
            if (result.Succeeded)
            {
                return LocalRedirect(returnUrl);
            }
            if (result.IsLockedOut)
            {
                return RedirectToPage("./Lockout");
            }
            else
            {
                // If the user does not have an account, then ask the user to create an account.
                AssociateExistingAccount = "false";
                ReturnUrl = returnUrl;
                ProviderDisplayName = info.ProviderDisplayName;
                if (info.Principal.HasClaim(c => c.Type == ClaimTypes.Email))
                {
                    Input = new InputModel
                    {
                        Email = info.Principal.FindFirstValue(ClaimTypes.Email)
                    };
                }
                return Page();
            }
        }
        private IdentityUser CreateUser()
        {
            try
            {
                return Activator.CreateInstance<IdentityUser>();
            }
            catch
            {
                throw new InvalidOperationException($"Can't create an instance of " +
                    $"'{nameof(IdentityUser)}'. " +
                    $"Ensure that '{nameof(IdentityUser)}' is not an abstract class " +
                    $"and has a parameterless constructor, or alternatively " +
                    $"override the external login page in " +
                    $"/Areas/Identity/Pages/Account/ExternalLogin.cshtml");
            }
        }
        private IUserEmailStore<IdentityUser> GetEmailStore()
        {
            if (!_userManager.SupportsUserEmail)
            {
                throw new NotSupportedException(
                    "The default UI requires a user store with email support.");
            }
            return (IUserEmailStore<IdentityUser>)_userStore;
        }
    }
}

 

image

When we run the project and click the external login provider button…

 

image

…we are authenticated, but not logged in, and our account is not created yet.

Also, clicking the Register button wont do anything at this point.

To handle the Register button, add the following method to ExternalLogin.cshtml.cs:

 

public async Task<IActionResult> OnPostConfirmationAsync(string returnUrl = null)
{
    returnUrl = returnUrl ?? Url.Content("~/");
    // Get the information about the user from the external login provider
    var info = await _signInManager.GetExternalLoginInfoAsync();
    if (info == null)
    {
        ErrorMessage = "Error loading external login information during confirmation.";
        return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
    }
    if (ModelState.IsValid)
    {
        var user = CreateUser();
        await _userStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
        await _emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);
        var result = await _userManager.CreateAsync(user);
        if (result.Succeeded)
        {
            result = await _userManager.AddLoginAsync(user, info);
            if (result.Succeeded)
            {
                var userId = await _userManager.GetUserIdAsync(user);
                var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
                code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
                var callbackUrl = Url.Page(
                    "/Account/ConfirmEmail",
                    pageHandler: null,
                    values: new { area = "Identity", userId = userId, code = code },
                    protocol: Request.Scheme);
                await _emailSender.SendEmailAsync(Input.Email, "Confirm your email",
                    $"Please confirm your account by " +
                    $"<a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>" +
                    $"clicking here</a>.");
                // If account confirmation is required, we need to show the link if
                // we don't have a real email sender
                if (_userManager.Options.SignIn.RequireConfirmedAccount)
                {
                    return RedirectToPage("./RegisterConfirmation", new { Email = Input.Email });
                }
                await _signInManager.SignInAsync(user, isPersistent: false, info.LoginProvider);
                return LocalRedirect(returnUrl);
            }
        }
        else
        {
            // There is an existing account 
            // Check if that account has a password 
            var ExistingUserToCheck = await _userManager.FindByEmailAsync(user.Email);
            if (ExistingUserToCheck != null)
            {
                if (ExistingUserToCheck.PasswordHash == null)
                {
                    StringBuilder PasswordNotSetError = new StringBuilder();
                    PasswordNotSetError.Append("There is an existing account with that email address. ");
                    PasswordNotSetError.Append("However, that account has no password set. ");
                    PasswordNotSetError.Append("Please log in to that account, with the ");
                    PasswordNotSetError.Append("existing external login method, and set a password. ");
                    PasswordNotSetError.Append("Then you can associate it with additional external ");
                    PasswordNotSetError.Append("login methods.");
                    AssociateExistingAccount = "blocked";
                    ModelState.AddModelError(string.Empty, PasswordNotSetError.ToString());
                    return Page();
                }
            }
            // We can associate this login to the existing account
            AssociateExistingAccount = "true";
        }
        // Display any errors that occurred
        // Usually says email is already used
        foreach (var error in result.Errors)
        {
            ModelState.AddModelError(string.Empty, error.Description);
        }
    }
    ProviderDisplayName = info.ProviderDisplayName;
    ReturnUrl = returnUrl;
    return Page();
}

 

Note: When a user logs in, using an external login, if they click Register button or the Log in button, they always end up hitting: public async Task<IActionResult> OnPostConfirmationAsync(string returnUrl = null)

Add the following code to ExternalLogin.cshtml:

 

@if (Convert.ToString(ViewData["AssociateExistingAccount"]) == "true")
{
    <div class="row">
        <div class="col-md-4">            
            <form asp-page-handler="AssociateLogin" asp-route-returnUrl="@Model.ReturnUrl" 
            method="post">
                <div asp-validation-summary="ModelOnly" class="text-danger"></div>
                <p>To associate this external login with the existing account, 
                    please enter the password for that account.</p>
                <div class="form-floating">
                    <input asp-for="Input.Password" class="form-control" autocomplete="off" />
                    <label asp-for="Input.Password" class="form-label"></label>
                    <span asp-validation-for="Input.Password" class="text-danger"></span>
                </div>
                <button type="submit" class="w-100 btn btn-lg btn-primary">Associate Account
                    
                </button>
            </form>
        </div>
    </div>
}

 

image

Now, when we run the project and click the Register button, we are presented with an option to enter the password of the existing account to associate the login.

However, at this point, clicking the Associate Account button won’t do anything.

Now, add the following code to ExternalLogin.cshtml (to handle the situation where we cannot associate an account because it doesn’t have a password):

 

@if (Convert.ToString(ViewData["AssociateExistingAccount"]) == "blocked")
{
    <div class="row">
        <div class="col-md-4">
            <form asp-page-handler="#" asp-route-returnUrl="@Model.ReturnUrl"
              method="post">
                <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            </form>
        </div>
    </div>
}

 

Finally, add the following method to ExternalLogin.cshtml.cs to handle the Associate Account button:

 

public async Task<IActionResult> OnPostAssociateLoginAsync(string returnUrl = null)
{
    returnUrl = returnUrl ?? Url.Content("~/");
    // Set AssociateExistingAccount so we return to this method on postback
    AssociateExistingAccount = "true";
    // Get the information about the user from the external login provider
    var ExternalLoginUser = await _signInManager.GetExternalLoginInfoAsync();
    if (ExternalLoginUser == null)
    {
        ErrorMessage = "Error loading external login information during confirmation.";
        return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
    }
    if (Input.Password != null)
    {
        try
        {
            // Get email of the ExternalLoginUser
            string ExternalLoginUserEmail = "";
            if (ExternalLoginUser.Principal.HasClaim(c => c.Type == ClaimTypes.Email))
            {
                ExternalLoginUserEmail =
                    ExternalLoginUser.Principal.FindFirstValue(ClaimTypes.Email);
            }
            // Check password against user in database
            var user = await _userManager.FindByEmailAsync(ExternalLoginUserEmail);
            if (user != null)
            {
                var CheckPasswordResult =
                    await _userManager.CheckPasswordAsync(user, Input.Password);
                if (CheckPasswordResult)
                {
                    // user found and password is correct
                    // add external login to user and sign in
                    var AddLoginResult =
                        await _userManager.AddLoginAsync(user, ExternalLoginUser);
                    if (AddLoginResult.Succeeded)
                    {
                        await _signInManager.SignInAsync(user, isPersistent: false);
                        return LocalRedirect(returnUrl);
                    }
                    else
                    {
                        foreach (var error in AddLoginResult.Errors)
                        {
                            ModelState.AddModelError(string.Empty, error.Description);
                        }
                    }
                }
                else // password is incorrect
                {
                    ModelState.AddModelError(string.Empty, "Password is incorrect");
                }
            }
        }
        catch (Exception ex)
        {
            ModelState.AddModelError(string.Empty, ex.Message);
            return Page();
        }
    }
    else
    {
        ModelState.AddModelError(string.Empty, "Password is required");
    }
    // If we got this far, something failed, redisplay form
    return Page();
}

 

image

Now the page will allow us to associate the login to the existing account.

 

Links

 

Download

The project is available on the Downloads page on this site.

You must have Visual Studio 2022 (or higher) installed to run the code.

An error has occurred. This application may no longer respond until reloaded. Reload 🗙