3. Implementing Basic Authentication in Razor Pages Without a Database

3. Implementing Basic Authentication in Razor Pages Without a Database

·

10 min read

Authentication and Authorization

To bolster the security of our portal, it's essential to implement authentication and authorization. Let's begin by briefly clarifying these terms.

Authentication involves confirming the identity of a user or service, while authorization is responsible for determining their access privileges.

In the context of ASP.NET Core, the recommended approach is to leverage the built-in authentication provider, ASP.NET Core Identity. You can find detailed information on implementing this provider in the provided resources.

However, for our specific project, I propose a simpler, custom authentication mechanism. ASP.NET Core Identity, with its comprehensive set of components like EF Core, SignInManagers, UserManager, etc., may be excessive for smaller, single-user applications like ours. We can streamline the process without the need for such elaborate components.

Let’s go to the WebApplicationBuilderExtensions class.

In the ConfigureServices method, I have added 2 lines of code.

The first line configures authentication for the applications. It uses cookie-based authentication, which is a common method for handling user authentication in ASP.NET Core.

builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
                .AddCookie();

The second line configures authorization services for the application.

builder.Services.AddAuthorization();

In the ConfigurePipeline method, I have added 2 middlewares for the HTTP request pipeline to enable authentication and authorization services.

app.UseAuthentication();
app.UseAuthorization();

Lastly, to ensure that all Razor Pages are restricted to authorised users, please include the following line:

app.MapRazorPages().RequireAuthorization();

The completed codebase for the WebApplicationBuilderExtensions class.

Now, press F5 once more to launch the project, and you will be directed to the /Account/Login page.

Cookie-based authentication has been successfully enabled.

Nevertheless, an issue has arisen - no page named Login exists within the Account folder.

You might be wondering where the /Account/Login path originates.

This is the default path for the Login page in cookie authentication. You can verify this by reviewing the source code for CookieAuthenticationDefaults here.

We certainly can modify the default Login path as shown below:

Nevertheless, I've opted to follow the default approach and create a page and folder accordingly. Therefore, I'll remove the options.LoginPath configuration and simply use .AddCookie().

Next, let's create an Account folder within the Pages directory.

To create a new Razor Page named Login, right-click on Account folder and choose the following:

Make sure we select Razor Page - Empty option and name it as Login.cshtml.

Please implement Login.cshtml.cs as illustrated below:

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;

namespace Portal.Pages.Account;

[AllowAnonymous]
public class LoginModel : PageModel
{
    [TempData]
    public string ErrorMessage { get; set; }
    public string ReturnUrl { get; set; }
    [BindProperty, Required]
    public string Username { get; set; }
    [BindProperty, DataType(DataType.Password)]
    public string Password { get; set; }

    public void OnGet(string returnUrl = null)
    {
        if (!string.IsNullOrEmpty(ErrorMessage))
        {
            ModelState.AddModelError(string.Empty, ErrorMessage);
        }

        returnUrl = returnUrl ?? Url.Content("~/");

        ReturnUrl = returnUrl;
    }

    public async Task<IActionResult> OnPostAsync(string returnUrl = null)
    {
        returnUrl = returnUrl ?? Url.Content("~/");

        if (ModelState.IsValid)
        {
            var verificationResult = true; // TODO: Verify username and password

            if (verificationResult)
            {
                var claims = new List<Claim>
                {
                    new Claim(ClaimTypes.Name, Username)
                };
                var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
                await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(identity));

                return Redirect(returnUrl);
            }

            ModelState.AddModelError(string.Empty, "Invalid login attempt.");
        }

        // If we got this far, something failed, redisplay form
        return Page();
    }
}

Please implement Login.cshtml as illustrated below:

@page
@model Portal.Pages.Account.LoginModel
@{
    Layout = null;
}

<!DOCTYPE html>
<html>

<head>
    <title>Login | Simple App Test</title>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
    <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
    <script src="~/lib/jquery/dist/jquery.min.js"></script>
    <script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
    <script src="~/js/site.js" asp-append-version="true"></script>
</head>

<body class="body">
    <div class="container">
        <div class="row">
            <form id="loginForm" method="post" class="col-md-4 mx-auto">
                <div class="text-center mt-5" style="font-size: 20px;">
                    Simple App Test
                </div>
                <hr />
                <div asp-validation-summary="ModelOnly" class="text-danger"></div>
                <div class="accordion">
                    <div class="form-group">
                        <label asp-for="Username"></label>
                        <input asp-for="Username" class="form-control" autocomplete="off" />
                        <span asp-validation-for="Username" class="text-danger"></span>
                    </div>
                    <div class="form-group">
                        <label asp-for="Password"></label>
                        <input asp-for="Password" class="form-control" autocomplete="off" />
                        <div class="invalid-feedback" style="margin-top: 0; font-size: 16px;">
                            The Password field is required.
                        </div>
                    </div>
                    <div class="form-group text-center mt-2">
                        <button id="loginSAT" type="submit" class="btn btn-outline-info">Log in</button>
                    </div>
                </div>
            </form>
        </div>
    </div>

    <partial name="_ValidationScriptsPartial" />
    <style>
        .form-control.is-invalid, .was-validated .form-control:invalid {
            background-image: none;
            border-color: #ced4da;
        }
    </style>

    <script>
        $(function () {
            $('#loginSAT').on('click', function () {
                let $password = $('#Password');
                $password.removeClass('is-invalid');
                let password = $password.val();

                if (!password) {
                    $password.addClass('is-invalid');
                }

                $('#loginForm').validate();
                if (($('#loginForm').valid() === false) || !password)
                    return false;

                return true;
            });
        });
    </script>
</body>
</html>

Now, press F5 once more to run the project, and you should see the following layout:

Enter any username and password, and you will be redirected to the home page again as shown below:

We have successfully logged into the portal! The most important piece of code is the line below:

await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(identity));

The purpose of this code is to sign in a user by creating a user session with a cookie-based authentication scheme. After successful sign-in, the user will have an associated identity with claims, which can be used for authorization and personalization within the application.

However, we are not quite yet finished with this login mechanism. We still need to figure out a way to store a user credential and verify the password.

Note that I have left this comment line below:

var verificationResult = true; // TODO: Verify username and password

Securing User Credentials

Rule number one, we never store passwords in plain text.

As we are not using ASP.NET Core Identity, we need to figure out a way to safely store user passwords.

The best recommended practice is to generate a password hash and password salt for each password. Ideally, we should store password salt separately. For simplicity, I will just store both hash and salt together.

Password hashing involves cryptographic algorithms. Unfortunately, cryptography is a topic too big to be discussed here. You can learn more about it here and here.

For now, I will use a sample code from here to generate hash and salt.

Let’s create a console application called PasswordHasher.

Implement Program.cs as shown below:

using System.Security.Cryptography;
using System.Text;

namespace PasswordHasher
{
    internal class Program
    {
        static void Main(string[] args)
        {
            const int keySize = 64;
            const int interations = 350_000;
            HashAlgorithmName hashAlgorithm = HashAlgorithmName.SHA512;

            var saltInBytes = RandomNumberGenerator.GetBytes(keySize);

            Console.Write("Enter password: ");
            var password = Console.ReadLine();

            var hashInBytes = Rfc2898DeriveBytes.Pbkdf2(
                Encoding.UTF8.GetBytes(password!),
                saltInBytes,
                interations,
                hashAlgorithm,
                keySize);

            Console.WriteLine();

            var salt = Convert.ToHexString(saltInBytes);
            var hash = Convert.ToHexString(hashInBytes);

            Console.WriteLine($"Salt: \n{salt}");
            Console.WriteLine();
            Console.WriteLine($"Hash: \n{hash}");
        }
    }
}

Now, run the console application and enter your desired password. Press Enter and you will see generated salt and hash. Copy both and store them somewhere else (e.g. Notepad) for later use.

Storing User Credentials

Go back to our Portal project, we will store your user credentials in a custom section in the appsettings.json file. appsettings.json file is a standard file when we create the project in Visual Studio.

You might be wondering about the use of appsettings.json for storing user credentials. In truth, it's not a secure approach. However, for the sake of simplicity, we'll use this JSON file for now, to enhance the project by transitioning to a more robust data store, such as an SQL Server database.

A better approach is to represent user information by creating a class that encapsulates configuration values in a strongly typed manner.

Create a folder named Models at the root of the application.

Add a new C# class file named User.cs.

Content of User.cs as shown below:

namespace Portal.Models;

public class User
{
    public string Username { get; set; }
    public string Password { get; set; }
    public string Salt { get; set; }
}

Add the User section to the appsettings.json with your username, hashed password and salt that you generated earlier.

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "User": {
    "Username": "<your username>",
    "Password": "<your hashed password>",
    "Salt": "<your password salt>"
  }
}

With this setup, we can now bind the User setting in appsettings.json to the C# User model class object.

To achieve this, we need to use the interface IConfiguration provided by the built-in .NET configuration provider.

Learn more about Configuration in .NET here.

Head back to the Login.cshtml.cs file.

We need to inject IConfiguration into the LoginModel class via constructor.

public LoginModel(IConfiguration configuration)
{
    _configuration = configuration;
}

Create a private field for storing configuration variable in the constructor.

private readonly IConfiguration _configuration;

Next, we need to use _configuration field to resolve user credentials from appsettings.json.

Find the following line in Login.cshtml.cs file:

var verificationResult = true; // TODO: Verify username and password

Replace with the following line:

var user = _configuration.GetSection("User").Get<User>();
var verificationResult = Username == user.Username && Password == user.Password;

The final changes for Login.cshtml.cs as shown below:

As you may have observed, the password validation will not meet the condition since we're comparing the hashed password with the actual input password from the page. However, it's important to note that the primary aim of this exercise is to verify that IConfiguration can successfully retrieve user credentials from appsettings.json.

Let’s put a breakpoint on the line below:

Now, press F5 to run the project. Enter any random username and password and press Log in button.

Visual Studio will pause the process on the breakpoint and if we inspect the user variable, we should be able to see the values which are resolved from appsettings.json.

Having completed the preceding steps, we are now prepared to add the final piece of code to fully implement the login mechanism.

Log In

To authenticate a user, we must verify both the username and password.

Verifying the username involves a simple comparison.

When it comes to validating the password, the process begins by hashing the user's input password with the stored salt from appsettings.json. Following that, we need to compare both the stored hash and the hashed user input password.

Now, let's proceed to create a private method at the bottom of the LoginModel class, as demonstrated below:

private bool VerifyPassword(string password, string hash, string salt)
{
    const int keySize = 64;
    const int iterations = 350000;
    HashAlgorithmName hashAlgorithm = HashAlgorithmName.SHA512;

    var hashToVerify = Rfc2898DeriveBytes.Pbkdf2(
        Encoding.UTF8.GetBytes(password),
        Convert.FromHexString(salt),
        iterations,
        hashAlgorithm,
        keySize);

    return CryptographicOperations.FixedTimeEquals(hashToVerify, Convert.FromHexString(hash));
}

We will utilise the VerifyPassword method to validate against the stored user credentials from appsettings.json.

Let’s remove the code below:

var verificationResult = Username == user.Username && Password == user.Password;

Replace with below:

var verificationResult = Username == user.Username && VerifyPassword(Password, user.Password, user.Salt);

The final content for Login.cshtml.cs as shown below:

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Configuration;
using Portal.Models;
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;

namespace Portal.Pages.Account;

[AllowAnonymous]
public class LoginModel : PageModel
{
    private readonly IConfiguration _configuration;

    [TempData]
    public string ErrorMessage { get; set; }
    public string ReturnUrl { get; set; }
    [BindProperty, Required]
    public string Username { get; set; }
    [BindProperty, DataType(DataType.Password)]
    public string Password { get; set; }

    public LoginModel(IConfiguration configuration)
    {
        _configuration = configuration;
    }

    public void OnGet(string returnUrl = null)
    {
        if (!string.IsNullOrEmpty(ErrorMessage))
        {
            ModelState.AddModelError(string.Empty, ErrorMessage);
        }

        returnUrl = returnUrl ?? Url.Content("~/");

        ReturnUrl = returnUrl;
    }

    public async Task<IActionResult> OnPostAsync(string returnUrl = null)
    {
        returnUrl = returnUrl ?? Url.Content("~/");

        if (ModelState.IsValid)
        {
            var user = _configuration.GetSection("User").Get<User>();
            var verificationResult = Username == user.Username && VerifyPassword(Password, user.Password, user.Salt);

            if (verificationResult)
            {
                var claims = new List<Claim>
                {
                    new Claim(ClaimTypes.Name, Username)
                };
                var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
                await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(identity));

                return Redirect(returnUrl);
            }

            ModelState.AddModelError(string.Empty, "Invalid login attempt.");
        }

        // If we got this far, something failed, redisplay form
        return Page();
    }

    private bool VerifyPassword(string password, string hash, string salt)
    {
        const int keySize = 64;
        const int iterations = 350000;
        HashAlgorithmName hashAlgorithm = HashAlgorithmName.SHA512;

        var hashToVerify = Rfc2898DeriveBytes.Pbkdf2(
            Encoding.UTF8.GetBytes(password),
            Convert.FromHexString(salt),
            iterations,
            hashAlgorithm,
            keySize);

        return CryptographicOperations.FixedTimeEquals(hashToVerify, Convert.FromHexString(hash));
    }
}

Press F5 to run the project. We are now able to log in with the correct username and password and automatically be redirected to the main page.

Log Out

To log out user, it is quite simple by just adding the code below:

HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);

Create a new Razor Page named Logout.

Replace content of Logout.cshtml.cs with below:

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace Portal.Pages.Account;

[AllowAnonymous]
public class LogoutModel : PageModel
{
    public async Task<IActionResult> OnGetAsync()
    {
        await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
        return RedirectToPage("Login");
    }
}

Replace content of Logout.cshtml with below:

@page
@model LogoutModel
@{
    ViewData["Title"] = "Log out";
}

<header>
    <h1>@ViewData["Title"]</h1>
    <p>You have successfully logged out of the application.</p>
</header>

Finally, locate _Layout.cshtml file in Pages\Shared folder.

Find the line of code as shown below:

<div class="navbar-nav">
    <div class="nav-item text-nowrap">
        <a class="nav-link px-3" href="#">Sign out</a>
    </div>
</div>

Replace with code below:

<div class="navbar-nav">
    <div class="nav-item text-nowrap">
        <a class="nav-link px-3" href="/Account/Logout">Sign out</a>
    </div>
</div>

That’s it. We are now able to log users out by clicking the Sign out link and be redirected to the Logout page. The page has OnGetAsync handler method which runs on GET requests. In the handler method, we have HttpContext.SignOutAsync which signs out users and redirects users back to the Login page.

Download source code

Github repository

Did you find this article valuable?

Support Han Chee by becoming a sponsor. Any amount is appreciated!