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.