Two-Factor Authentication with Google Authenticator in ASP.NET Core
By FoxLearn 2/11/2025 3:52:09 AM 130
The process is straightforward: the user scans a QR code, which enables them to generate time-based passwords for authentication.
Why Choose TOTP Over SMS and Email?
While there are various ways to implement multi-factor authentication (MFA), SMS-based methods are commonly used. In these systems, you send an SMS or email containing a one-time code, which the user enters to authenticate themselves. While this method does improve security compared to single-factor authentication, it is no longer recommended. TOTP is a more secure and reliable approach for 2FA, and it’s widely considered a better option for protecting your app.
Another emerging option is Passkeys, which offer passwordless authentication and don’t rely on MFA in the traditional sense.
How TOTP Works
TOTP is a simple yet effective system. When the user requests 2FA setup, the server generates a secret key and returns it to the frontend. The frontend creates a QR code that the user scans with their TOTP app (e.g., Google Authenticator). The app stores the secret key and begins generating one-time passcodes based on the current timestamp, changing every 30 seconds.
On the server side, the same secret is stored securely in the database, and both the server and the app use the same algorithm to generate and verify the one-time password.
Implementation
Let’s dive into how to implement TOTP in an ASP.NET Core MVC application.
Install the Otp.NET NuGet package: This package is used for generating MFA secrets and validating one-time passcodes. You can install it from NuGet: Otp.NET.
Install a QR code generator library: We’ll use the QR code generation library QRCode.js to generate the QR code for the user. Download it and place it in your
wwwroot
folder (e.g.,wwwroot/lib/qrcode/qrcode.js
).
Models
Our project uses a User
model, which includes a field for storing the MFA secret. There is no need to impose a character limit on the MFASecret
field since 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
The AccountController
is responsible for handling account management, including the MFA setup. It also handles encryption of the MFA secret before storing it in the database.
public class AccountController : Controller { private readonly AppDbContext _dbContext; private readonly byte[] key; public AccountController(AppDbContext dbContext) { _dbContext = dbContext; key = Encoding.UTF8.GetBytes("your-random-32-byte-key-here"); } }
Registering the MFA Secret
In the Management
view, we display the option for users to enable 2FA. If the user doesn’t already have an MFA secret, the server generates a new secret and passes it to the frontend. The frontend then generates a QR code, which the user scans with their TOTP app to start generating passcodes.
@{ 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: Here, users can configure their MFA Secret.
public IActionResult Management() { if (HttpContext.Session.GetString("UserId") != null) { var user = _dbContext.Users.Where(s => s.Id == HttpContext.Session.GetInt32("UserId")).FirstOrDefault(); if (user?.MFASecret == null) { 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"); } }
Handling the MFA Secret
When a user enables 2FA, the backend checks if they already have a secret. If they do not, a new one is generated and sent to the frontend for the user to scan. This secret is temporarily stored in TempData
to ensure it isn’t exposed in the frontend.
Login Action
In the LoginUser
action, we authenticate the user with their password. If MFA is enabled, we prompt them for the second factor. The session tracks whether the user has entered the correct password before moving on to the MFA verification.
[HttpPost] public IActionResult LoginUser(string username, string password) { var user = _dbContext.Users.Where(s => s.Username == username).FirstOrDefault(); if (user == null || password != user.Password) { ViewBag.CredentialError = "Invalid username or password"; return View("Login"); } if (user.MFASecret != null) { 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"); }
On the 2FA verification page, the user enters the correct OTP. The system first checks if the user’s password is correct. Then, it retrieves and decrypts the stored MFA secret, computes the expected OTP, and compares it with the user's input. If they match, the user is fully authenticated. The system also tracks the number of failed attempts using an OTP counter and resets the session after three failed attempts, prompting the user to log in again. Optionally, IP blocking or account locking can be implemented to prevent excessive attempts.
@{ 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>
MFA Verification
The user is then asked to enter the code generated by their TOTP app. The backend compares this code to the one generated on the server to confirm the user’s identity.
[HttpPost] public IActionResult VerifyMFA(string AuthCode) { var userId = HttpContext.Session.GetInt32("OTPUserId"); var counter = HttpContext.Session.GetInt32("OTPCounter"); if (userId != null && counter.HasValue && counter > 0) { var user = _dbContext.Users.FirstOrDefault(s => s.Id == userId); if (user == null || user.MFASecret == null) { Response.StatusCode = 400; return Redirect("Login"); } string secret = Decrypt(user.MFASecret); var totp = new Totp(Base32Encoding.ToBytes(secret)); 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"); } int remainingCounter = counter.Value - 1; if (remainingCounter <= 0) { HttpContext.Session.Remove("OTPUserId"); HttpContext.Session.Remove("OTPCounter"); ViewBag.CredentialError = "Too many failed attempts. Please log in again."; return Redirect("Login"); } ViewBag.MFAError = "Invalid code. Please try again."; HttpContext.Session.SetInt32("OTPCounter", remainingCounter); return View("MFAVerification"); } Response.StatusCode = 401; return Redirect("Login"); }
Storing Secrets Securely
It’s important to encrypt the MFA secret before storing it in the database. We use AES encryption for this purpose, ensuring that the secrets are not exposed in plaintext.
private string Encrypt(string input) { using (Aes aes = Aes.Create()) { byte[] iv = new byte[16]; new Random().NextBytes(iv); 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); } } return "enc:" + Convert.ToBase64String(iv) + ":::" + Convert.ToBase64String(ms.ToArray()); } } } private string Decrypt(string input) { if (string.IsNullOrEmpty(input) || !input.StartsWith("enc:")) throw new Exception("The input isn't encrypted"); input = input.Substring(4); var parts = input.Split(":::"); using (Aes aes = Aes.Create()) { 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(); } } } } }
This implementation provides a simple but secure 2FA system for your application using TOTP. It ensures that the MFA secret is securely generated, encrypted, and stored. Additionally, it provides a robust way to handle MFA verification, protecting your users from unauthorized access.
- Basic Authentication in ASP.NET Core
- How to Implement Stripe Payment Gateway in ASP.NET Core
- Comparing ASP.NET SOAP Services and Core APIs
- How to fix System.InvalidOperationException: Scheme already exists: Identity.Application
- Implementing Passkeys to ASP.NET Core Application
- How to Implement Passkey Authentication in ASP.NET Core
- Implementing Google Authentication in the Blazor WebAssembly App
- How to Add a custom InputFormatter in ASP.NET Core