Securing Your ASP.NET App with 2FA Using Nexmo SMS and SendGrid Email
Published on May 17, 2021

2FA (2 Factor Authentication) is a must nowadays to increase the security within your application. It is seen in all kinds of apps: from the signup process to user action verification. The most common types of 2FA are phone verification and email verification.

In this tutorial we'll show how to set up 2FA in your .NET application using ASP .NET Identity, the Nexmo C# Client Library for SMS auth and the SendGrid C# Client for email auth.

If you just want to see the result you can take a look at the video or grab the code.

Setup ASP .NET MVC application

Open Visual Studio and create a new ASP .NET MVC application. For this demo, we'll delete the Contact & About sections from the default generated website.

Install the Nexmo Client to your app via NuGet Package Manager

Add the Nexmo Client to your application via the NuGet Package Console.

PM> Install-Package Nexmo.Csharp.Client

Install the SendGrid client via NuGet Package Manager

PM> Install-Package SendGrid -Version 8.0.3

Add Nexmo and SendGrid credentials

For the purpose of the demo we'll put the Nexmo and SendGrid credentials in the <appSettings> section of the Web.config file. If we were developing this application for distribution we may chose to enter these credentials in our Azure portal.

<add key="Nexmo.Url.Rest" value="https://rest.nexmo.com">
<add key="Nexmo.Url.Api" value="https://api.nexmo.com">
<add key="Nexmo.api_key" value="NEXMO_API_KEY">
<add key="Nexmo.api_secret" value="NEXMO_API_SECRET">
<add key="SMSAccountFrom" value="SMS_FROM_NUMBER">
<add key="mailAccount" value="SENDGRID_API_KEY">
</add></add></add></add></add></add>

Plug in Nexmo in the SMS Service, SendGrid in the Email Service

Inside the IdentityConfig.cs file, add the SendGrid configuration in the SMSService method. Then, plug in the Nexmo Client inside the SMSService method of the IdentityConfig.cs file.

Remember to add the using directives for the Nexmo.Api and SendGrid namespaces, and any other namespaces that are flagged as missing.

public class EmailService : IIdentityMessageService
{
    public async Task SendAsync(IdentityMessage message)
    {
        // Plug in your email service here to send an email.
        await configSendGridasync(message);
    }
    private async Task configSendGridasync(IdentityMessage message)
    {
        string apiKey = ConfigurationManager.AppSettings["mailAPIKey"];       
        dynamic sg = new SendGridAPIClient(apiKey, "https://api.sendgrid.com");
        
        Email from = new Email("demo@nexmo.com");
        string subject = message.Subject;
        Email to = new Email(message.Destination);
        Content content = new Content("text/plain", message.Body);
        Mail mail = new Mail(from, subject, to, content); 

        dynamic response = await sg.client.mail.send.post(requestBody: mail.Get()); 
    }
}

public class SmsService : IIdentityMessageService
{
    public Task SendAsync(IdentityMessage message)
    {
        var sms = SMS.Send(new SMS.SMSRequest
        {
            from = ConfigurationManager.AppSettings["SMSAccountFrom"],
            to = message.Destination,
            text = message.Body
        });
        return Task.FromResult(0);
    }
}

Add 'SendEmailConfirmationTokenAsync()' method to 'AccountController'

Add the following method to your AccountController which will be called on user registration to send a confirmation email to the provided email address.

private async Task<string> SendEmailConfirmationTokenAsync(string userID, string subject)
{
    string code = await UserManager.GenerateEmailConfirmationTokenAsync(userID);
    var callbackUrl = Url.Action("ConfirmEmail", "Account",  new { userId = userID, code = code }, protocol: Request.Url.Scheme);
    await UserManager.SendEmailAsync(userID, subject, "Please confirm your account by clicking <a href="" +="" callbackurl="" ""="">here</a>");
    return callbackUrl;
}
</string>

Update 'Register' action method

Inside the Register method of the AccountController, add a couple properties to newly created variable of the ApplicationUser type: TwoFactorEnabled (true), PhoneNumberConfirmed (false). Once the user is successfully created, store the user ID in a session state and redirect the user to the AddPhoneNumber action method in the ManageController.

[AllowAnonymous]
public ActionResult AddPhoneNumber()
{
    return View();
}

[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<actionresult> Register(RegisterViewModel model)
{
    if (ModelState.IsValid)
    {
        var user = new ApplicationUser { UserName = model.Email, Email = model.Email, TwoFactorEnabled = true, PhoneNumberConfirmed = false};
        var result = await UserManager.CreateAsync(user, model.Password);
        if (result.Succeeded)
        {
            Session["UserID"] = user.Id;
            return RedirectToAction("AddPhoneNumber", "Manage");
        }
        AddErrors(result);
    }
    // If we got this far, something failed, redisplay form
    return View(model);
}
</actionresult>

Check DB for existing phone number and add SMS logic to the AddPhoneNumber action method

In the ManageController add the [AllowAnonymous] attribute to both the GET & POST AddPhoneNumber action methods. This gives the currently unregistered user access to the phone number confirmation workflow. Make a database query to check if the phone number entered by the user is previously associated with an account. If not, redirect the user to the VerifyPhoneNumber action method.

[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<actionresult> AddPhoneNumber(AddPhoneNumberViewModel model)
{
    if (!ModelState.IsValid)
    {
        return View(model);
    }
    var db = new ApplicationDbContext();
    if (db.Users.FirstOrDefault(u => u.PhoneNumber == model.Number) == null)
    {
        // Generate the token and send it
        var code = await UserManager.GenerateChangePhoneNumberTokenAsync((string)Session["UserID"], model.Number);
        if (UserManager.SmsService != null)
        {
            var message = new IdentityMessage
            {
                Destination = model.Number,
                Body = "Your security code is: " + code
            };
            await UserManager.SmsService.SendAsync(message);
        }
        return RedirectToAction("VerifyPhoneNumber", new { PhoneNumber = model.Number });
    }
    else
    {
        ModelState.AddModelError("", "The provided phone number is associated with another account.");
        return View();
    }
}
</actionresult>

Update VerifyPhoneNumber Action method

Add the [AllowAnonymous] attribute to the GET action method and delete everything in the method but the return statement that directs the verification flow,

[AllowAnonymous]
public async Task<actionresult> VerifyPhoneNumber(string phoneNumber)
{
    return phoneNumber == null ? View("Error") : View(new VerifyPhoneNumberViewModel { PhoneNumber = phoneNumber });
}
</actionresult>

Replace User.Identity.GetUserId() with Session["UserID"] in the method as shown below. If the user successfully enters the pin code, they are directed to the Index view of the ManageController. The User's boolean property PhoneNumberConfirmed is then set to true.

[AllowAnonymous]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<actionresult> VerifyPhoneNumber(VerifyPhoneNumberViewModel model)
{
    if (!ModelState.IsValid)
    {
        return View(model);
    }
    var result = await UserManager.ChangePhoneNumberAsync((string)Session["UserID"], model.PhoneNumber, model.Code);
    if (result.Succeeded)
    {
        var user = await UserManager.FindByIdAsync((string)Session["UserID"]);
        if (user != null)
        {
            await SignInManager.SignInAsync(user, isPersistent: false, rememberBrowser: false);
        }
        return RedirectToAction("Index", new { Message = ManageMessageId.AddPhoneSuccess });
    }
    // If we got this far, something failed, redisplay form
    ModelState.AddModelError("", "Failed to verify phone");
    return View(model);
}
</actionresult>

Check if the user has a confirmed email on Login

Back in the AccountController, update the Login() action method to check to see if the user has confirmed their email or not. If not, return an error message and redirect the user to the "Info" view. Also, call the SendEmailConfirmationTokenAsync() method passing in the user.Id and an email subject.

[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<actionresult> Login(LoginViewModel model, string returnUrl)
{
    if (!ModelState.IsValid)
    {
        return View(model);
    }

    var user = await UserManager.FindByNameAsync(model.Email);
    if (user != null)
    {
        if (!await UserManager.IsEmailConfirmedAsync(user.Id))
        {
            string callbackUrl = await SendEmailConfirmationTokenAsync(user.Id, "Confirm your account");
            ViewBag.title = "Check Email";
            ViewBag.message = "You must have a confirmed email to login.";
            return View("Info");
        }
    }

    ...
</actionresult>

Add Info View

Inside the Views/Account, create a new View named Info that the user will be redirected to if their email has not been confirmed. The view should contain the following code:

<h2>@ViewBag.Title.</h2>
<h3>@ViewBag.Message</h3>

Ensure 2FA Cannot be Bypassed

In the Views/Account/Login.cshtml delete the <div class="form-group"> containing the 'Remember Me' checkbox. In Views/Account/VerifyCode.cshtml delete the <div class="form-group"> for the "RememberBrowser" checkbox and the hidden RememberMe input. Delete the corresponding variable in each of the view models in AccountViewModels.cs: SendCodeViewModel and VerifyCodeViewModel. Finally, remove any usage of these variables (including method signatures) or where required replace the usage of these variables in the two with false. This will restrict the user from bypassing 2FA verification.

Conclusion

With that, you have a web app using ASP .NET Identity that is 2 Factor Authentication (2FA) enabled using Nexmo SMS and SendGrid Email as the different methods of verification.

SMS and email provide additional layers of security to correctly identify users and further protect sensitive user information. Using the Nexmo C# Client Library and SendGrid's C# Client, you can add both SMS and email verification with ease.

Please grab the code and try it for yourself.

Feel free to send me any thoughts/questions on Twitter @sidsharma_27 or email me at sidharth.sharma@nexmo.com!

Sidharth Sharma

Ready to start building?

Experience seamless connectivity, real-time messaging, and crystal-clear voice and video calls-all at your fingertips.