The two-step verification is a process where a user enters credentials, and after successful password validation, receives an OTP (one-time-password) via email or SMS. Then, they enter that OTP in the Two-Step Verification form on our site to log in successfully.
We just have to make one thing clear before we start. Even though many people think that this type of verification is based on something you know (the password) and something you have (device to access Email or SMS), that’s not the case. The ownership of the device is not part of the verification process, on the other hand, the OTP is. Therefore, the OTP is still something we know and it raises some security concerns over email or SMS. But it is still more secure than a one-time password process.
So, in this article, we are going to learn how to implement a two-step verification process in our project by using ASP.NET Core Identity.
To navigate through the entire series, visit the ASP.NET Core Identity series page.
VIDEO: Better Web API Security With Two Factor Authentication Using Identity.
Code Preparation for Two-Step Verification Process
Before we continue, we have to make sure that our user has an email confirmed and a two factor enabled. If we check our only user in the database, we are going to see this is the case:
If these columns are not set to true (1), you can set them manually.
Now, we can modify the Login
action:
[HttpPost] [ValidateAntiForgeryToken] public async Task<IActionResult> Login(UserLoginModel userModel, string returnUrl = null) { //code removed for clarity reasons var result = await _signInManager.PasswordSignInAsync(userModel.Email, userModel.Password, userModel.RememberMe, lockoutOnFailure: true); if (result.Succeeded) { return RedirectToLocal(returnUrl); } if(result.RequiresTwoFactor) { return RedirectToAction(nameof(LoginTwoStep), new { userModel.Email, userModel.RememberMe, returnUrl}); } //code removed for clarity reasons }
So, one of the properties that our result
variable contains is RequiresTwoFactor
. The PasswordSignInAsync
method will set that property to true if the TwoFactorEnabled
column for the current user is set to true, and the Succeeded
property will be set to false. Therefore, we check if the RequiresTwoFactor
property is true and if it is, we redirect a user to a different action with the email, rememberMe and returnUrl parameters.
It is important to mention that as soon as a user gets redirected to the LoginTwoStep
action, the new Identity.TwoFactorUserId
cookie will be created in our browser. This cookie contains important data about the current user.
Before we create additional actions, let’s create a new model class for the LoginTwoStep
action:
public class TwoStepModel { [Required] [DataType(DataType.Text)] public string TwoFactorCode { get; set; } public bool RememberMe { get; set; } }
Now, we can add two required actions in the Account controller:
[HttpGet] public async Task<IActionResult> LoginTwoStep(string email, bool rememberMe, string returnUrl = null) { return View(); } [HttpPost] [ValidateAntiForgeryToken] public async Task<IActionResult> LoginTwoStep(TwoStepModel twoStepModel, string returnUrl = null) { return View(); }
Excellent.
We have prepared everything for the two-step verification process. So, let’s implement it.
Implementation of the Two-Step Verification Process
It’s time to modify the GET LoginTwoStep action:
[HttpGet] public async Task<IActionResult> LoginTwoStep(string email, bool rememberMe, string returnUrl = null) { var user = await _userManager.FindByEmailAsync(email); if (user == null) { return View(nameof(Error)); } var providers = await _userManager.GetValidTwoFactorProvidersAsync(user); if (!providers.Contains("Email")) { return View(nameof(Error)); } var token = await _userManager.GenerateTwoFactorTokenAsync(user, "Email"); var message = new Message(new string[] { email }, "Authentication token", token, null); await _emailSender.SendEmailAsync(message); ViewData["ReturnUrl"] = returnUrl; return View(); }
We check if the current user exists in the database. If that’s not the case, we display the error page. But if we find a user, we have to check is there a provider for Email because we want to send our two-step code using an email message. After that check, we just create a token with the GenerateTwoFactorTokenAsync
method and send an email message.
Now, let’s just create a view for this action:
@model IdentityByExamples.Models.TwoStepModel <h1>LoginTwoStep</h1> <hr /> <p> Please enter your authentication code in the field below</p> <div class="row"> <div class="col-md-4"> <form asp-action="LoginTwoStep" asp-route-returnUrl ="@ViewData["ReturnUrl"]"> <div asp-validation-summary="All" class="text-danger"></div> <div class="form-group"> <label asp-for="TwoFactorCode" class="control-label"></label> <input asp-for="TwoFactorCode" class="form-control" /> <span asp-validation-for="TwoFactorCode" class="text-danger"></span> </div> <input asp-for="RememberMe" type="hidden" /> <div class="form-group"> <input type="submit" value="Log in" class="btn btn-primary" /> </div> </form> </div> </div> @section Scripts { @await Html.PartialAsync("_ValidationScriptsPartial") }
This process is quite familiar.
LoginTwoStep POST Implementation
Finally, let’s modify the POST LoginTwoStep action:
[HttpPost] [ValidateAntiForgeryToken] public async Task<IActionResult> LoginTwoStep(TwoStepModel twoStepModel, string returnUrl = null) { if (!ModelState.IsValid) { return View(twoStepModel); } var user = await _signInManager.GetTwoFactorAuthenticationUserAsync(); if(user == null) { return RedirectToAction(nameof(Error)); } var result = await _signInManager.TwoFactorSignInAsync("Email", twoStepModel.TwoFactorCode, twoStepModel.RememberMe, rememberClient: false); if(result.Succeeded) { return RedirectToLocal(returnUrl); } else if(result.IsLockedOut) { //Same logic as in the Login action ModelState.AddModelError("", "The account is locked out"); return View(); } else { ModelState.AddModelError("", "Invalid Login Attempt"); return View(); } }
So, first, we check the model validity. If it’s valid, we use the GetTwoFactorAuthenticationUserAsync
method to get the current user. We do that with the help of our Identity.TwoFactorUserId
cookie, created in the first part of this article. This will prove to us that the user indeed went through all the verification steps to get to this point. If we find that user, we use the TwoFactorSignInAsync
method to verify the TwoFactorToken value and sign in the user.
If the result is successful, we use the returnUrl
parameter to redirect the user. Otherwise, we apply additional checks on the result
variable and force appropriate actions.
We don’t want to repeat the same code – that’s why you see the comment in the code sample. But it would be a good practice to extract the code from the Lockout part in the Login action in its own method and then just call that method in the Login and LoginTwoStep actions.
Testing the Entire Process
With everything in place, we can test our functionality.
As soon as we enter valid credentials, we are going to see a new view:
Then if we check our email:
We can see a new token.
Finally, after we enter that token in the input field, we are going to be redirected either to the home view or the protected view. This depends on what we tried to access without authentication:
Take notice that the amr
property (Authentication Method Reference) now has the mfa
value which stands for Multiple-factor Authentication.
Conclusion
So, in this article, we’ve learned how to use a two-step verification process to authenticate a user by using an email provider. But, we don’t have to use only email provider. SMS is also an option that can be used for the process. The same logic applies to the SMS provider.
In the next article, we are going to learn about external accounts and how to handle external identity providers.
So, stay tuned.