Hello world! In this article, we’ll look at how to implement two-factor authentication in your ASP.NET Core web application with Time-Based One-time Password (TOTP) apps such as Google Authenticator and Authy. Simply the user will scan the QR code and start creating their time-based passwords, then authenticate themselves.

You can go to the repo of this project and star it to follow the next authentication updates and support me.

https://github.com/ycanatilgan/Authentication-Playground

Why use TOTP over SMS and E-Mail

Numerous methods exist to build an MFA structure. The SMS, for instance, is one of many options used on the internet. They are quite simple, send an SMS or E-Mail from your backend, then check if the user entered the correct key and authenticate them. Despite they still enhance the security compared to single-factor authentication, they are no longer recommended.

However, using SMS as a second factor is no longer recommended. Too many known attack vectors exist for this type of implementation.

https://learn.microsoft.com/en-us/aspnet/core/security/authentication/mfa?view=aspnetcore-8.0

So to make your app more secure, it is better to choose TOTP as a second factor. There is also another, kind of new, approach called Passkeys. It doesn’t require MFA in a secure way and doesn’t need users to keep passwords. It is the future of passwords. We will be covering it in the next article.

How TOTP works

TOTP is fairly simple. First, we create a secret key in the backend and return it to the frontend when the user requests. The frontend will create a QR Code of this secret to save users the trouble of typing it manually. When users scan the QR Code, their TOTP app (such as Google Authenticator) saves the secret and starts displaying digits in real time with the timestamp. On the other hand, your app also saves the secret in the database. Your app and the user’s TOTP app use the same algorithm to create one-time passwords. This algorithm uses the secret and the current timestamp so that the password changes with the time (30 seconds in our case).

Implementation

Now that we discussed how TOTP works, let’s dive in and implement it in our application. The project is created with .NET 8 MVC. Let’s first include the packages that we need:


1. Install the NuGet package named Otp.NET https://www.nuget.org/packages/Otp.NET/1.3.0?_src=template. This will create MFA secrets and compute the MFA digits to check if the user entered the correct one.

2. Install the QR Code generator JS library: https://davidshimjs.github.io/qrcodejs/. You should download it and put it inside the wwwroot folder. To make it the same with this project, you should put it in wwwroot/lib/qrcode/qrcode.js. Otherwise, change the directory in the “Management.cshtml” file later.

Models

We do have a single model for this project, which is for the User entity. We have a place for MFASecret, which obviously will store our secret. Do not give a character limit to it, as we will encrypt it later.

public class Users
{
    [Key]
    public int Id { get; set; }
    [Required]
    [MaxLength(50)]
    public string Username { get; set; }
    [Required]
    public string Password { get; set; }
    public string? MFASecret { get; set; }
}

Account Controller

We have an AccountController that holds the actions for account management.

public class AccountController : Controller
{
    AppDbContext _dbContext;
    private readonly byte[] key;

    public AccountController(AppDbContext dbContext) 
    { 
        _dbContext = dbContext;

        //Replace the KEY!!!!!!!!!!!!!!!!!!!
        key = Encoding.UTF8.GetBytes("pKjvjST5-oC+nDrz?sghX5GHo-cl4Obn");
    }
}

We construct the class with the DB Context to do CRUD operations in the database and a random key for our AES encryption algorithm as we will store the secret encrypted in the database.

WARNING! Replace the key value in the constructor with your key. This key is created for example. You can create a random one at: https://www.avast.com/random-password-generator#pc. Set the length to 32 to create a 32-byte key. Also, you can store it in a better way, for example, in a proxy server, which we cannot cover in this article.

Registering MFA Secret

I created a view page to allow users to activate 2FA. In the backend, we check if the user has an MFA secret in the database. If they have, we display that you have already enabled 2FA for your account, otherwise, we create a random secret and send it to frontend. The frontend creates a QR Code with the library we already imported to our project of this secret. We also give the secret in plaintext, as the user may want to enter it manually into their app.

We create the secret in the backend, and store it in the TempData, instead of the frontend. So that we do not allow users to put their secrets as this might be used by attackers (you will see how).

@{
    ViewData["Title"] = "User Management";
    string username = @Context.Session.GetString("Username");
}

@model string?

<h1>@ViewData["Title"]</h1>

@if (!string.IsNullOrEmpty(ViewBag.MFAError))
{
    if (ViewBag.MFAError == "WrongCode")
    {
        <div class="alert alert-info w-100" role="alert">
            Wrong Code! Please try again!
        </div>
    }
}

<div class="row g-4">
    @if (!string.IsNullOrEmpty(Model))
    {
        <div class="col-xl-3 col-lg-6 col-md-12">
            <h4 class="mb-1 pt-2 text-center">Activate two factor authentication</h4>
            <p class="text-center">
                <span>Scan the QR Code to your OTP App</span>
            </p>
            <script src="~/lib/qrcode/qrcode.js"></script>
            <div id="qrcode-container" class="text-center ms-3">
                <div id="qrcode"></div>
            </div>
            <script type="text/javascript">
                new QRCode(document.getElementById("qrcode"),
                    'otpauth://totp/@username?secret=@Model&issuer=ycanindev.com');
            </script>
            <br />
            <p class="text-center">
                <span>or enter the code manually</span>
            </p>

            <p class="text-center">
                <span class="text-muted">@Model</span>
            </p>

            <div class="my-4 text-center">
                <div class="divider-text">then</div>
            </div>
            <p class="text-center text-center">
                <span>Enter the pin that your app creates</span>
            </p>
            <form id="twoStepsForm" action="/Account/RegisterMFASecret" method="POST">
                <div class="mb-3">
                    <div class="auth-input-wrapper numeral-mask-wrapper">
                        <input type="tel"
                               class="form-control auth-input h-px-50 numeral-mask mx-1 my-2 text-center"
                               maxlength="6"
                               name="AuthCode"/>
                    </div>
                </div>
                <button class="btn btn-primary d-grid w-100 mb-3 text-center">Send</button>
            </form>
        </div>
    }
    else
    {
        <h4 class="mb-1 pt-2 text-center">You already enabled 2-Factor Authentication, Great!</h4>
    }
</div>
//User Management page, where they can set MFA Secret
public IActionResult Management()
{
    //If no username exists in the session, then it is unauthorized attempt, redirect to login page
    if (HttpContext.Session.GetString("UserId") != null)
    {
        //If user has MFA secret in the DB, do not create a secret and do not send to the view
        //Thus the view will show users already have MFA enabled
        if (_dbContext.Users
            .Where(s => s.Id == HttpContext.Session.GetInt32("UserId") && s.MFASecret == null)
            .Any())
        {
            //Remove older created MFA secrets in any case
            TempData.Remove("MFASecret");

            //CREATE a random key with OTP.NET package, convert it into base 64 and-
            //send it to view to allow users to scan the code and create their OTP codes.
            //Save it into TempData, so that you can later get it and compute the code-
            //to check if user is able to setup it correctly
            var key = KeyGeneration.GenerateRandomKey(20);
            string base64Key = Base32Encoding.ToString(key);
            TempData["MFASecret"] = base64Key;

            return View("Management", base64Key);
        }
        else
        {
            return View("Management", string.Empty);
        }
    }
    else
    {
        Response.StatusCode = 401;
        return Redirect("Login");
    }
}

To save the secret to the database, we have an action method RegisterMFASecret. This action confirms that the user was able to set the 2FA correctly, by checking if they have entered the correct value. If so, the secret is encrypted and saved into the database.

One trick here is to not allow the user to override their existing secret. In the frontend, we set the pages correctly, normally user cannot reach this action method. However, in case of a bug or an attack, the override request may reach the server. As a bug example in this project, the user may have two sessions simultaneously, and send the request from each. If we don’t check it before updating it, the secret will be overridden.

But why shouldn’t we allow users to override it? For instance, if you have an XSS vulnerability in your app, the attacker can send a request to this action and override the user’s secret with their secret. (Even if we don’t check, this attack still would not work in our project, because we do not rely on user-created secrets, we create secrets in the backend and store them in the TempData. Still, it is a good practice to check it.) If you need an action to reset the MFA, this should be in another action, by authenticating users properly.

Login Action

We have a LoginUser action to authenticate the user with the first factor, the password. Check the database for the username, and compare the passwords (obviously very bad way in this example). If all is good, check if the user has an MFA secret in the database. If don’t, it means the user has not activated MFA for their account.

Now down to the bedrock, if an MFA secret exists, you should authenticate the user only for the first factor. What does it mean? We use sessions to check the authentication (or maybe authorization) of users in this project. As you see if there is no MFA secret, we set “Username” and “UserId” to the session, and later we can look up the session and check who is the user or if the user is authenticated or not. When the user with the MFA key enters the correct credentials, DO NOT GIVE THEM THE SAME AUTHENTICATION same as the user is fully authenticated. In our case, it would be wrong to set the username and the userid in the session.

In another example, if your authentication process is with the JWT, change the issuer or claims or maybe even sign it with another key so that the JWT you created for TOTP cannot replace your main authentication JWT.

But why do we have to authenticate users somehow? Because we cannot trust frontend claims for this process. We somehow have to validate that this user has entered their password correctly and can be fully authorized only by entering their MFA code correctly. If we only redirected them to the MFA validation page with the user ID in the frontend and did not authenticate them at all, we would be creating a vulnerable app, allowing people who know the user IDs to get access by only entering the MFA digits.

Also, it is nice to add some kind of counter to limit the login attempts in a single authorization. I initially set OTPCounter as 3, we will check and update the counter later. So that the user can attempt to log in only 3 times in the specific session.

WARNING! Never store the password as plaintext as I do in this project. This is for a quick demonstration of the 2FA structure. Always hash and salt passwords and save them.

[HttpPost]
public IActionResult LoginUser(string username, string password)
{
    Users? user = _dbContext.Users.Where(s => s.Username == username).FirstOrDefault();

    //Check if username exists in db
    if (user == null) 
    {
        ViewBag.CredentialError = "User not found";
        return View("Login");
    }

    //WARNING! NEVER DEPLOY THIS INTO PRODUCTION, THIS IS FOR A QUICK SHOWCASE,
    //YOU SHOULD ALWAYS KEEP PASSWORDS HASHED AND COMPARE THE HASHED VALUES
    if(string.Equals(password, user.Password))
    {
        //If user has MFA secret in the DB, then user has to enter their OTP code to login
        //If no MFA secret is available, you can authenticate the user, since they did not-
        //have MFA configured.
        if(user.MFASecret != null)
        {
            //Careful here. You have to give user a some kind of server side verification-
            //to allow them enter their 2-step code and login. You cannot only trust client-
            //side. You can give them a special session value that confirms they have entered-
            //their password correctly and can login only by entering their 2-step code
            //You can find more info in: http://ycanindev.com
            //We also add a counter to the session, we will check it later to limit the
            //attempts via single login.
            HttpContext.Session.SetInt32("OTPUserId", user.Id);
            HttpContext.Session.SetInt32("OTPCounter", 3);
            return View("MFAVerification");
        }

        HttpContext.Session.SetString("Username", user.Username);
        HttpContext.Session.SetInt32("UserId", user.Id);
        return Redirect("~/Home/Index");
    }
    else
    {
        ViewBag.CredentialError = "Password isn't correct";
        return View("Login");
    }           
}

}

On the 2FA verification page, we only expect the user to enter the correct digits, and send the action. The action checks if the user passed the first factor, the password. Then gets the user’s secret from the database and decrypts it. Finally, compute the correct digits to compare them with the user’s digits. If the digits are ok, then the user is fully authenticated and can start using the service.

Don’t forget to check and update OTPCounter! Read its value and decrease it by one for each failed login attempt. If it is down to zero, clear the session and redirect the user to login again. You may also limit the attempts with the password, and block their IP or account for a while. But that is not this article’s topic.

@{
    ViewData["Title"] = "Enter Your 2FA Code";
}


<h1>@ViewData["Title"]</h1>

@if (!string.IsNullOrEmpty(ViewBag.MFAError))
{
    if (ViewBag.MFAError == "WrongCode")
    {
        <div class="alert alert-info w-100" role="alert">
            Wrong Code! Please try again!
        </div>
    }
}

<div class="row g-4">
 
    <div class="col-xl-3 col-lg-6 col-md-12">
        <form action="/Account/VerifyMFA" method="POST">
            <div class="mb-3">
                <div class="auth-input-wrapper numeral-mask-wrapper">
                    <input type="tel"
                            class="form-control auth-input h-px-50 numeral-mask mx-1 my-2 text-center"
                            maxlength="6"
                            name="AuthCode"/>
                </div>
            </div>
            <button class="btn btn-primary d-grid w-100 mb-3 text-center">Send</button>
        </form>
    </div>

</div>
[HttpPost]
public IActionResult VerifyMFA(string AuthCode)
{
    //Check the special session value to understand if user is entered their password correctly-
    //and redirected to the MFA page.
    //Also check the counter to limit the attempts, if more than 3, user will need to login back again
    var UserId = HttpContext.Session.GetInt32("OTPUserId");
    var Counter = HttpContext.Session.GetInt32("OTPCounter");
    if (UserId != null && Counter.HasValue && Counter > 0)
    {
        //Get the secret from database, and compute the correct otp code to see if it matches
        var user = _dbContext.Users.Where(s => s.Id == UserId).FirstOrDefault();

        if(user == null || user.MFASecret == null)
        {
            Response.StatusCode = 400;
            return Redirect("Login");
        }

        //Decrypt to get the true secret
        string secret = Decrypt(user.MFASecret);

        var totp = new Totp(Base32Encoding.ToBytes(secret.ToString()));
        var totpCode = totp.ComputeTotp(DateTime.UtcNow.AddSeconds(-1));

        if (AuthCode == totpCode)
        {
            HttpContext.Session.Remove("OTPUserId");
            HttpContext.Session.Remove("OTPCounter");

            HttpContext.Session.SetString("Username", user.Username);
            HttpContext.Session.SetInt32("UserId", user.Id);
            return Redirect("~/Home/Index");
        }
        else
        {
            int remainingCounter = Counter.Value - 1;

            //If user has tried 3 times and none of them was successful, direct them to the login page again, or even ban the ip
            if(remainingCounter <= 0)
            {
                HttpContext.Session.Remove("OTPUserId");
                HttpContext.Session.Remove("OTPCounter");

                ViewBag.CredentialError = "You need to login again!";
                Response.StatusCode = 401;
                return Redirect("Login");
            }

            ViewBag.MFAError = "Wrong code! Try again!";
            HttpContext.Session.SetInt32("OTPCounter", remainingCounter);
            return View("MFAVerification");
        }
    }
    else
    {
        Response.StatusCode = 401;
        return Redirect("Login");
    }
}

Encryption/Decryption for Storing Secret

If secrets are revealed from the database as plaintext, they can be used to create 2FA digits of users. It is better to keep them encrypted in the database.

I rely on AES encryption to store the user secrets in the database and use an app-wide password. Many options are available; some like to encrypt it with the user’s password (which could be complicated and less secure if security measures aren’t good enough). For simplicity, I do not give details about them. You can work around it to find the best way for you.

In this example set a 32-byte key in the class constructor, and use it to encrypt the secret. I also added a random IV, which gives uniqueness to each secret, and stored it as plaintext. I also added the prefix “enc:” to check if the data is encrypted or not in a simple way. When your project scales, it may become useful.

private string Encrypt(string input)
{
    using (Aes aes = Aes.Create())
    {
        //Create IV to give uniquness to each entity
        //More detail in the tutorial http://ycanindev.com

        byte[] randomBytes = new byte[16];
        new Random().NextBytes(randomBytes);
        byte[] iv = randomBytes;

        aes.Key = key;
        aes.IV = iv;

        ICryptoTransform encryptor = aes.CreateEncryptor(aes.Key, aes.IV);

        using (MemoryStream ms = new MemoryStream())
        {
            using (CryptoStream cs = new CryptoStream(ms, encryptor, CryptoStreamMode.Write))
            {
                using (StreamWriter sw = new StreamWriter(cs))
                {
                    sw.Write(input);
                }
            }

            //I prefer to add a prefix ("enc:" here)  to understand if data is encrypted or not in a simple way.
            //Add iv in plain text and split it by encrypted data, the decrypt method will further understand-
            //this structure and proccesses accordingly.
            return "enc:" + Convert.ToBase64String(iv) + ":::" + Convert.ToBase64String(ms.ToArray());
        }
    }
}

private string Decrypt(string input)
{
    //Check if data is encrypted
    //You can handle this error in your own way, I'll just throw an exception to stop the process.
    if (string.IsNullOrEmpty(input) || !input.StartsWith("enc:"))
        throw new Exception("The input isn't encrypted");

    using (Aes aes = Aes.Create())
    {
        //Strip off the "enc:" prefix
        input = input.Substring(4);
        var parts = input.Split(":::");

        //Split the IV and the encrypted value
        aes.Key = key;
        aes.IV = Convert.FromBase64String(parts[0]);

        ICryptoTransform decryptor = aes.CreateDecryptor(aes.Key, aes.IV);

        using (MemoryStream ms = new MemoryStream(Convert.FromBase64String(parts[1])))
        {
            using (CryptoStream cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Read))
            {
                using (StreamReader sr = new StreamReader(cs))
                {
                    return sr.ReadToEnd();
                }
            }
        }
    }
}

Happy Coding!

That is all needed to build a 2FA in your app! You can check the repo of this project and star it to follow the next authentication implementations to it.

https://github.com/ycanatilgan/Authentication-Playground

ycanindev@gmail.com

By ycan

Leave a Reply

Your email address will not be published. Required fields are marked *