Two-step authentication is nowadays common requirement for any web application development, as a developer we must make sure that we allow right user to access data, to do that authentication more secure after first authentication we again produce a OTP (one time password) and send to user by SMS or in email.
Here is the graphical representation of how the multi factor authentication will look like.
Multi factor authentication often termed as MFA, indicates that authentication can have multiple layer to conform user credentials, Two-step Authentication is the part of multifactor authentication, here in example we demonstrate how to perform two step authentication in asp.net core using Identity framework service.
If you are not familiar with Identity framework, please read my earlier post about how to configure identity service in asp.net core
In this example, we will create an asp.net core razor page application to demonstrate two-step authentication, then same example can be applied on Asp.net core MVC application also.
Here are the stepsMicrosoft.AspNetCore.Identity Microsoft.AspNetCore.Identity.EntityFrameworkCore Microsoft.EntityFrameworkCore.Design Microsoft.EntityFrameworkCore.SqlServer
@page @model TwoStepsAuthentication.Pages.loginModel <h1>Login page</h1> <div>@Model.ActionMessage</div> <form method="post"> Username:<input asp-for="Username" /> <br /> Password:<input asp-for="Password" /> <br /> <input type="submit" value="Login" /> </form>
We can login with either email or with username, though the actual login method accept username and user object as first parameter, here is how we call the method PasswordSignInAsync
.
var result = _signInManager.PasswordSignInAsync(UserName, Password, false, false); Microsoft.AspNetCore.Identity.SignInResult _result = result.Result;
The above example was logging with username and password
Below is the example of how we can login with email and find the user object.
var appUser = _userManager.FindByEmailAsync(Email); AppUser _user = appUser.Result; var result = _signInManager.PasswordSignInAsync(_user, Password, false, false); Microsoft.AspNetCore.Identity.SignInResult _result = result.Result; if (_result.Succeeded){ }
on post method write the following logic.
public async IActionResult OnPost() { try { var appUser = await _userManager.FindByEmailAsync(Username); if (appUser != null) { /*signout and signin with new user*/ await _signInManager.SignOutAsync(); var _result = await _signInManager.PasswordSignInAsync(appUser, Password, false, false); Microsoft.AspNetCore.Identity.SignInResult result = _result; /*if successfull signin, go for next authentication*/ if (result.Succeeded) { if (appUser.TwoFactorEnabled) { // Force TFA page and send OTP using email or SMS return Redirect(string.Format("tfaAuth?username=" + Username)); } else { // allow user without TFA return Redirect("controlpanel"); } } else ActionMessage = "Login failed"; } else ActionMessage = "No user found!"; } catch (Exception ex) { ActionMessage = ex.ToString(); } return Page(); }
Here i have removed SMS or email sending code, just to keep this post more focused on two steps authentication, in case you want to know more about SMS and email sending, please refer following posts.
Here is the key logic for checking if two factor authentication is enabled for that particular user, if yes, then only we should perform two factor authentication check by sending OTP using SMS or Email, otherwise let the user log in without TFA
Microsoft.AspNetCore.Identity.SignInResult result = await _signInManager.PasswordSignInAsync(appUser, Password, false, false); /*if successfull signin, go for next authentication*/ if (result.Succeeded) { if (appUser.TwoFactorEnabled) { // Force to TFA page and send OTP using email or SMS return Redirect(string.Format("tfaAuth?username=" + Username)); } else return Redirect("controlpanel"); }
As you can see in above code the user property indicate if TwoFactorEnabled is true appUser.TwoFactorEnabled
(database table "AspNetUsers", field name "TwoFactorEnabled"),
ideally user should have that option to enable or disable two factor authentication as per their choice,
but depending on application nature or criticality you also can make the TwoFactorEnabled true for all the user by default.
This page also will have a form, which will allow user to enter their OTP (one time password) and then submit, if OTP is confirmed then user will be redirected to control panel page, otherwise show the failure error message.
<h1>Confirm OTP</h1> <div>@Model.ActionMessage</div> <form method="post"> OTP : <input asp-for="OTP" /> <br /> <input type="submit" value="Confirm OTP" /> </form>
On load of this page the OTP will be sent to User email and SMS (if configured), but we should also provide an additional button to send OTP again, in case OTP was not delivered in first attempt.
public async void OnGet() { AppUser appUser = await _userManager.FindByEmailAsync(_userName); var _twoFactorEnabled = await _userManager.GetTwoFactorEnabledAsync(appUser); var tokenEmail = await _userManager.GenerateTwoFactorTokenAsync(appUser, _userManager.Options.Tokens.EmailConfirmationTokenProvider); string messageString = string.Format("Authentication token: {0}", token); }
In above code we are generating a token, which is long encrypted string value, this type of value we can send in email with and url and query string, we also can generate a 6 digit number and send in email or SMS , then ask user to type the same number in two step interface we provide in application.
// This is how you can generate six digits random code int getSixDigitCode() { Random _random = new Random(); int _aftCode = _random.Next(100001, 999999); Session["aftcode"] = _aftCode; return _aftCode; }
Now the question is where we should store the value, so when user enter the value either by clicking url or typing in interface , we need to compare if the value is correct!
There can be multiple options like storing in database, in cookie-encrypted format, or in session, Session would be the most secure and easy way to deal with the situation, if you wish learn how to use session in Asp.Net Core application.
Now before you send two-step authentication code either by email or by SMS, we should check if the user has confirmed email id and confirmed mobile number, below code will help to confirm both credentials.
if (appUser.EmailConfirmed) { // Send OTP in email } if (appUser.PhoneNumberConfirmed) { // Send OTP in SMS }
When user submit the OTP, we need to check if the OTP is correct.
public IActionResult OnPost() { var appUser = await _signInManager.GetTwoFactorAuthenticationUserAsync(); var authToken = await _userManager.GetAuthenticatorKeyAsync(appUser); bool isVerified = await _userManager.VerifyTwoFactorTokenAsync(appUser, "AspNetAuthenticator", authToken); return Page(); }
the method _userManager.GetAuthenticatorKeyAsync(appUser)
checks if user record is present in "AspNetUserTokens" table.
Now in control panel you can display user data, also let user know if their two factor authentication is enabled, if not, ask them to set that on!
Here are few lines of code in your razor page will allow you to do the same.
@using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Identity @inject SignInManager&llt;IdentityUser> _signInManager @inject UserManager&llt;IdentityUser> _userManager @inject IAuthorizationService _authorizationService <h1>control panel</h1> @if (_signInManager.IsSignedIn(User)) { @if ((_authorizationService.AuthorizeAsync(User, "TwoFactorEnabled")).Result.Succeeded) { <div> You have successfully logged in with two factor authentication. </div> } else { <div> You have NOT Enabled two factor authentication yet. </div> } } else { <div> Invalid ! </div> }
Now to test above scenario, you may need one user to login with, if you don;t have any user in your identity database, then create a new page, and in code behind post method write following code.
Create a new user for two factor authentication where EmailConfirmed=true;
and TwoFactor property is enabled
user.TwoFactorEnabled = true;
public void OnPost() { // create a new user AppUser user = new AppUser(); user.UserName = Username; user.Email = EmailAddress; user.EmailConfirmed = true; user.PhoneNumber = PhoneNumber; user.PhoneNumberConfirmed = true; user.TwoFactorEnabled = true; _userManager.CreateAsync(user,Password); ActionMessage = $"New user created with username {Username}"; }
The above "CreateAsync" method will not throw any error message, even if the user is not created due to some error, so instead of writing the above code, try using following property to find out error and check if the user created successfully.
var _result= _userManager.CreateAsync(user, Password); if (_result.Result.Succeeded) ActionMessage = $"New user created with username {Username}"; else foreach (IdentityError e in _result.Result.Errors) { str.Append(e.Description); } ActionMessage = str.ToString();
So far, we have seen the flow of how to implement two-factor authentication, but here is another important things you need to know how to register all services in startup.cs file.
public void ConfigureServices(IServiceCollection services) { services.AddDbContext<AppIdentityDbContext> (options => options.UseSqlServer(DbConnection.ConnectionString2)); services.AddIdentity<AppUser, IdentityRole>( options => { options.SignIn.RequireConfirmedAccount = true; } ) .AddEntityFrameworkStores<AppIdentityDbContext>() .AddTokenProvider<DataProtectorTokenProvider<AppUser>> (TokenOptions.DefaultProvider); services.Configure<IdentityOptions>(options => { options.Lockout.MaxFailedAccessAttempts = 3; options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5); }); #region Email and SMS(keep either one) services.AddSingleton<IEmailSender, EmailSender>(); services.AddSingleton<ISMSSender, TwilioSMSSender>(); services.AddSingleton<ISMSSender, AspSMSSender>(); services.Configure<SMSoptions>(Configuration); #endregion services.AddScoped<IUserClaimsPrincipalFactory<AppUser>, AdditionalUserClaimsPrincipalFactory>(); services.AddAuthorization(options => options.AddPolicy("TwoFactorEnabled", x => x.RequireClaim("amr", "mfa"))); services.AddRazorPages(); }
In above startup configuration code, you need to understand following things
services.AddIdentity<AppUser, IdentityRole>( options => { options.SignIn.RequireConfirmedAccount = true; } ) .AddEntityFrameworkStores<AppIdentityDbContext>() .AddTokenProvider<DataProtectorTokenProvider<AppUser>> (TokenOptions.DefaultProvider); services.AddAuthorization(options => options.AddPolicy("TwoFactorEnabled", x => x.RequireClaim("amr", "mfa")));
Another very useful feature to lockout user for sometime on multiple failure attempt, identity framework provide a ready to use mechanism to achieve the same.
services.Configure<IdentityOptions>(options => { options.Lockout.MaxFailedAccessAttempts = 3; options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5); });