.NET8 Identity: Register, Login, Email Confirmation, and Two-Factor Authentication (2FA)

Mohamed Mohsen
13 min readMay 11, 2024

--

Starting a new project and tackling authentication and authorization from scratch can be exhausted. It often involves creating login and registration features, which can be time-consuming. Challenges like managing refresh tokens and implementing two-factor authentication add to the complexity.

Thankfully, with the arrival of .NET 8, these tasks are greatly simplified, requiring minimal configuration.

In this article, I aim to guide you through various identity configuration scenarios, including:

  • Basic registration and login.
  • Securing endpoints with Bearer tokens.
  • Retrieving user information.
  • Email confirmation.
  • Resend email confirmation.
  • Change default settings and add custom user properties.
  • Configure two-factor authentication (MVC).

Prerequisites: .NET 8 or later.

Before proceeding, let me outline my current environment setup:

  • .NET Web API application.
  • Visual Studio 2022.
  • Utilizing SQLite to simulate a real database instead of an in-memory database.

Next, let’s install the necessary NuGet packages.

dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet add package Microsoft.EntityFrameworkCore.Sqlite
dotnet add package Microsoft.EntityFrameworkCore.Tools

Basic registration and login.

Firstly, start by creating the DbContext where all the identity-generated tables will be stored.

// IdentityUser is the Microsoft base identity class.
// creates empty scheme with just all the identity tables.
class AppDbContext : IdentityDbContext<IdentityUser>
{
public AppDbContext(DbContextOptions options) : base(options)
{
}
}

Next, we’ll register the authentication and authorization middleware's, offering the flexibility to choose between bearer tokens or cookies. In this example, we’ll opt for bearer tokens, facilitating the retrieval of a bearer token upon logging in.

// Program.cs
builder.Services.AddAuthentication()
.AddBearerToken(IdentityConstants.BearerScheme);

builder.Services.AddAuthorizationBuilder();

Following that, we’ll register our DbContext, which in this example is a SQLite database.

// Program.cs
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlite("DataSource=app.db");
});

After that, we need to connect the DbContext as the identity store so that all actions are directed towards the SQLite database.

// Program.cs
builder.Services.AddIdentityCore<IdentityUser>()
.AddEntityFrameworkStores<AppDbContext>()
.AddApiEndpoints();

Lastly, we need to add all the endpoints to be part of our application’s endpoints. This can be achieved by mapping the identity endpoints after building the builder.

app.MapIdentityApi<IdentityUser>();

Your Program.cs file should now resemble this structure.

using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

builder.Services.AddAuthentication()
.AddBearerToken(IdentityConstants.BearerScheme);

builder.Services.AddAuthorizationBuilder();

builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlite("DataSource=app.db");
});

builder.Services.AddIdentityCore<IdentityUser>()
.AddEntityFrameworkStores<AppDbContext>()
.AddApiEndpoints();

var app = builder.Build();

app.MapIdentityApi<IdentityUser>();


app.UseSwagger();
app.UseSwaggerUI();
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();

app.Run();

Before running your application, don’t forget to add a new migration and update the database by executing the following commands from the Package Manager Console.

Add-Migration initial
Update-Database

Now, run your application and open Swagger. You should see a collection of endpoints automatically generated for you.

Generated identity endpoints.

Attempt to register a new user. If you try to create a new user with an invalid email address or a password that doesn’t meet the criteria, the identity will return an error along with the specific details of the occurrence.

Now, let’s proceed with the login process. If the email or password provided is not valid, the identity will return a 401 Unauthorized status.

Securing endpoints with Bearer tokens.

After configuring the authentication to return a bearer token, we’ll need to utilize this bearer token with any protected endpoint that requires authorization.

It’s essential to note that the tokens generated are not standard JSON Web Tokens (JWT). This decision was intentional, as the built-in identity is primarily intended for simple scenarios. The token option is not meant to serve as a fully-featured identity service provider or token server but rather an alternative to the cookie option for clients that cannot use cookies.

Now, let’s attempt to access an authorized endpoint, specifically the /manage/info endpoint. If you try to access it from Swagger, it will show an unauthorized error. Next, use Postman to login, save the accessToken, and include it when calling the /manage/info endpoint. This time, it should return the desired results.

Retrieving user information

To retrieve the currently logged-in user’s information at any moment, utilize the ClaimsPrincipal class, which stores the information of the current logged-in user.

To achieve this with the Minimal API approach:

app.Map("/", (ClaimsPrincipal user) => $"Hello {user.Identity!.Name}")
.RequireAuthorization();

For the controller approach:

[ApiController]
[Route("[controller]")]
public class TestController: ControllerBase
{
[HttpGet]
[Authorize]
public string Get()
{
return User.Identity!.Name;
}
}

Ensure to note that the above examples utilize the “!” operator, implying that the identity is expected to always exist without the possibility of being null. This assumption holds true as both are authorized endpoints requiring prior login. However, it’s advisable to verify the existence of the identity to prevent potential null reference exceptions.

Email confirmation

Microsoft is recommending using SendGrid or another email service to send email rather than SMTP. SMTP is difficult to secure and set up correctly.

In this tutorial, SendGrid is used to send email. A SendGrid account and key is needed to send email. See Get Started with SendGrid for Free to register for a free SendGrid account.

1- Configure the SendGrid required information in the secrets.json file. Avoid adding these settings directly to the appsettings.json file to mitigate security risks.

{
"SendGridKey": "your send-grid key",
"From": "your registered email",
"Name": "your registered name"
}
Registered email and name
SendGrid API key

2- Install the necessary NuGet packages.

#.NET CLI
dotnet add package SendGrid
dotnet add package SendGrid.Extensions.DependencyInjection

3- Implement IEmailSender

To Implement IEmailSender, create EmailSender.cs with code similar to the following:

using Microsoft.AspNetCore.Identity.UI.Services;
using SendGrid;
using SendGrid.Helpers.Mail;

namespace Identity;

public class EmailSender : IEmailSender
{
private readonly ILogger _logger;
private readonly IConfiguration _configuration;

public EmailSender(IConfiguration configuration, ILogger<EmailSender> logger)
{
_configuration = configuration;
_logger = logger;
}

public async Task SendEmailAsync(string toEmail, string subject, string message)
{
var sendGridKey = _configuration["SendGridKey"];
ArgumentNullException.ThrowIfNullOrEmpty(sendGridKey, nameof(sendGridKey));
await Execute(sendGridKey, subject, message, toEmail);
}

public async Task Execute(string apiKey, string subject, string message, string toEmail)
{
var client = new SendGridClient(apiKey);

var msg = new SendGridMessage()
{
From = new EmailAddress(_configuration["From"], _configuration["Name"]),
Subject = subject,
PlainTextContent = message,
HtmlContent = message
};

msg.AddTo(new EmailAddress(toEmail));

// Disable click tracking.
// See https://sendgrid.com/docs/User_Guide/Settings/tracking.html
msg.SetClickTracking(false, false);

var response = await client.SendEmailAsync(msg);
_logger.LogInformation(response.IsSuccessStatusCode
? $"Email to {toEmail} queued successfully!"
: $"Failure Email to {toEmail}");
}
}

4- Inject EmailService into Program.cs to enable its usage.

// Program.cs
builder.Services.AddTransient<IEmailSender, EmailSender>();

5- Enforce sign-in confirmation and integrate the SendGrid service.

Now, if you attempt to register again, it will work, but you’ll notice that no email confirmation is sent. Additionally, users can log in without any confirmation being required.

To address this and ensure the correct flow, we need to first require email confirmation during the signing process. Then, we need to integrate the SendGrid service so that the identity framework can utilize it.

// Program.cs

builder.Services.AddIdentityCore<IdentityUser>(options =>
{
options.SignIn.RequireConfirmedEmail = true;
})

builder.Services.AddSendGrid(options =>
options.ApiKey = builder.Configuration["SendGridKey"]!
);

Your Program.cs file should now resemble this structure.

using Identity;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.UI.Services;
using Microsoft.EntityFrameworkCore;
using SendGrid.Extensions.DependencyInjection;
using System.Security.Claims;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

builder.Services.AddAuthentication()
.AddBearerToken(IdentityConstants.BearerScheme);

builder.Services.AddSendGrid(options =>
options.ApiKey = builder.Configuration["SendGridKey"]!
);

builder.Services.AddTransient<IEmailSender, EmailSender>();

builder.Services.AddAuthorizationBuilder();

builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlite("DataSource=app.db");
});

builder.Services.AddIdentityCore<IdentityUser>(options =>
{
options.SignIn.RequireConfirmedEmail = true;
})
.AddEntityFrameworkStores<AppDbContext>()
.AddApiEndpoints();

var app = builder.Build();

app.MapIdentityApi<IdentityUser>();

app.Map("/", (ClaimsPrincipal user) => $"Hello {user.Identity!.Name} from minimal apis.")
.RequireAuthorization();

app.UseSwagger();
app.UseSwaggerUI();
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();

app.Run();

Now, it’s time to test our application. Try registering a new user and check your email for a confirmation link. Clicking the link should change the confirmation status in the database to TRUE.

Remember to check your spam folder, as emails often end up there. Additionally, note that emails are queued before being sent, so expect a slight delay of a few seconds.

Register new user
Visual studio logs
User created in the databse
Confirmation link email

By clicking the link “clicking here,” it should redirect you back to localhost and update the user’s confirmation status in the database to TRUE.

Confirmation message
Confirmation status changed to TRUE

Lastly, with the SendGrid service injected, we can streamline our EmailService, resulting in fewer lines of code.

Here is an updated version of the EmailService that should effectively handle all the tested scenarios.


using Microsoft.AspNetCore.Identity.UI.Services;
using SendGrid;
using SendGrid.Helpers.Mail;

public class EmailSender : IEmailSender
{
private readonly ILogger _logger;
private readonly IConfiguration _configuration;
private readonly ISendGridClient _sendGridClient;


public EmailSender(IConfiguration configuration, ILogger<EmailSender> logger, ISendGridClient sendGridClient)
{
_configuration = configuration;
_logger = logger;
_sendGridClient = sendGridClient;
}

public async Task SendEmailAsync(string toEmail, string subject, string message)
{
var msg = new SendGridMessage()
{
From = new EmailAddress(_configuration["From"], _configuration["Name"]),
Subject = subject,
PlainTextContent = message,
HtmlContent = message
};
msg.AddTo(new EmailAddress(toEmail));

var response = await _sendGridClient.SendEmailAsync(msg);
_logger.LogInformation(response.IsSuccessStatusCode
? $"Email to {toEmail} queued successfully!"
: $"Failure Email to {toEmail}");
}
}

Resend email confirmation

You’ll certainly need the option to resend confirmation emails, a feature crucial for any application. .NET 8 Identity provides a resend confirmation endpoint: simply call /resendConfirmationEmail and set the body as follows:

{
"Email": "user email"
}

Change default settings and add custom user properties

Microsoft Identity also provides the capability to modify default settings or introduce new custom attributes.

Adjust default settings.

builder.Services.Configure<IdentityOptions>(options =>
{
// Password settings.
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequireNonAlphanumeric = true;
options.Password.RequireUppercase = true;
options.Password.RequiredLength = 6;
options.Password.RequiredUniqueChars = 1;

// Lockout settings.
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.AllowedForNewUsers = true;

// User settings.
options.User.AllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+";
options.User.RequireUniqueEmail = false;
});

Adding new custom properties into user attributes. This can be achieved by creating a new user class, AppUser, inheriting from IdentityUser.

class AppUser : IdentityUser 
{
public int MyProperty { get; set; }
}

Subsequently, a new migration needs to be added, and the new property should be integrated into the AspNetUserstable.

Remember to update every occurrence of IdentityUserin your application to AppUser

Configure two-factor authentication (MVC)

Before we go, here is some concepts it’s good to know before we start.

What is Multi-factor authentication (MFA)?

Multi-factor authentication (MFA) enhances security by requiring users to provide additional forms of identification during the sign-in process. This could include entering a code from a cellphone, using a FIDO2 key, or providing a fingerprint scan. By requiring a second form of authentication, MFA makes it more difficult for attackers to gain unauthorized access, as the additional factor is not easily obtained or duplicated.

What is Two-factor authentication (2FA)?

Two-factor authentication (2FA) is like a subset of MFA, but the difference being that MFA can require two or more factors to prove the identity.

What is TOTP (Time-based One-time Password Algorithm)?

MFA using TOTP is supported by default when using ASP.NET Core Identity. This approach can be used together with any compliant authenticator app, including:

  • Microsoft Authenticator
  • Google Authenticator

What is MFA SMS?

MFA with SMS increases security massively compared with password authentication (single factor). However, using SMS as a second factor is no longer recommended. Too many known attack vectors exist for this type of implementation.

What is Fast Identity Online (FIDO)?
FIDO is an open standard for passwordless authentication, allowing users to sign in without passwords. FIDO2 keys, typically USB but also Bluetooth or NFC, enhance security by removing password risks.
They are currently considered the most secure MFA methods available.

Now, let’s begin configuring the 2FA.

We’ll leverage the integration of 2FA with an MVC project. When you create a new MVC project for the first time, there’s an option to generate the identity system, including Register, Login, and 2FA, from the outset. Our first step is to create a new MVC project.

In Visual Studio 2022, you can accomplish the following:

Select the individual accounts identity option.

Alternatively, you can use the command line.

dotnet new mvc -n "2FA" -au individual

The created project should resemble the following.

Now, run the created project. A welcome page should appear with login and register buttons.

Click “Register” to create a new user.

Confirm your email by clicking “Click here to confirm your account.”

Next, logout and then login again. Click on your email at the top right.

Navigate to the Two-factor authentication section.

Click “Add authenticator app” The two-factor authentication setup screen should appear.

As you can see, it’s added out of the box for you, but without the option of scanning the QR code; it only works with a code.

Now, let’s add the QR code feature, allowing users to simply scan the QR code to add the key automatically.

We need to access the generated cshtml pages that were created for us. By default, these pages are located in Areas/Identity/Pages directory. However, if you haven’t expanded the directory, dotnet won’t show you the files unless they’re generated.

To do this, run the command.

dotnet tool install -g dotnet-aspnet-codegenerator

Next, we need to install all the necessary NuGet packages.

dotnet add pacakge Microsoft.EntityFrameworkCore.Design
dotnet add package Microsoft.VisualStudio.Web.CodeGeneration.Design

Then, run the code generator and specify which pages we want to retrieve. By default, if you leave it blank, it will generate numerous pages. However, to avoid cluttering your project with unnecessary pages, we’ll specify the specific page we need.

cd "your project directory"
dotnet aspnet-codegenerator identity -dc _2FA.Data.ApplicationDbContext --files "Account.Manage.EnableAuthenticator"

To view all the files that you can generate, run the command.

dotnet aspnet-codegenerator identity -dc _2FA.Data.ApplicationDbContext -lf

Here is a full list of all the files that could be generated.

File List:
Account._StatusMessage
Account.AccessDenied
Account.ConfirmEmail
Account.ConfirmEmailChange
Account.ExternalLogin
Account.ForgotPassword
Account.ForgotPasswordConfirmation
Account.Lockout
Account.Login
Account.LoginWith2fa
Account.LoginWithRecoveryCode
Account.Logout
Account.Manage._Layout
Account.Manage._ManageNav
Account.Manage._StatusMessage
Account.Manage.ChangePassword
Account.Manage.DeletePersonalData
Account.Manage.Disable2fa
Account.Manage.DownloadPersonalData
Account.Manage.Email
Account.Manage.EnableAuthenticator
Account.Manage.ExternalLogins
Account.Manage.GenerateRecoveryCodes
Account.Manage.Index
Account.Manage.PersonalData
Account.Manage.ResetAuthenticator
Account.Manage.SetPassword
Account.Manage.ShowRecoveryCodes
Account.Manage.TwoFactorAuthentication
Account.Register
Account.RegisterConfirmation
Account.ResendEmailConfirmation
Account.ResetPassword
Account.ResetPasswordConfirmation

Back in Visual Studio, you should now see the EnableAuthenticator.cshtml file added.

Before editing the EnableAuthenticator.cshtml file, we need to download the qrcode.js JavaScript library, which will render and generate the QR code for us. Save it in the wwwroot\lib folder.

Here, I renamed it to just qrcodejs

Next, create a new file call it qr.js in the wwwroot\jswith the following code.

window.addEventListener("load", () => {
const uri = document.getElementById("qrCodeData").getAttribute('data-url');
new QRCode(document.getElementById("qrCode"),
{
text: uri,
width: 150,
height: 150
});
});

Lastly, open the EnableAuthenticator.cshtml file then:

  • Update the Scripts section to add a reference to the qrcode.js library previously downloaded.
  • Add the qr.js file with the call to generate the QR code.
@section Scripts {
@await Html.PartialAsync("_ValidationScriptsPartial")

<script type="text/javascript" src="~/lib/qrcodejs/qrcode.js"></script>
<script type="text/javascript" src="~/js/qr.js"></script>
}

Run the application again, the QR code should now appear.

Scan it with your Authenticator app, and it should work perfectly.

By adding it to your authenticator app, you can verify the code from the generated codes in the app. It should show you the 2FA confirmation.

Now, try logging out and then logging in again. The 2FA form will appear, prompting you to enter a 2FA code.

Congratulations, your 2FA has been set up perfectly.

If you enjoyed this article and are hungry for more .NET and Azure content, consider following me for regular updates and fresh insights into the world of .NET and Azure development

--

--

Mohamed Mohsen

Software Engineer @Microsoft | .NET | Azure Cloud Services | System Design | Architecture